diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77cec9c..f50a282 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run ESLint - run: pnpm lint:check + - name: Lint + run: | + # Enforce zero warnings policy + pnpm lint:check 2>&1 | tee lint-output.txt + if grep -qE "✖.*problem" lint-output.txt; then + echo "Error: Linting issues detected. Run 'pnpm lint:fix' to resolve." + exit 1 + fi - name: Run Prettier run: pnpm format:check @@ -105,8 +111,9 @@ jobs: - name: Run tests run: pnpm test - - name: Run tests with coverage + - name: Test with coverage run: pnpm test:coverage + # Coverage thresholds enforced by jest.config.ts - name: Upload coverage artifacts if: matrix.node-version == '22.x' diff --git a/.gitignore b/.gitignore index 904f4c0..f0ca11e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,5 @@ pnpm-debug.log* .DS_Store Thumbs.db -output/ +/output/ coverage \ No newline at end of file diff --git a/README.md b/README.md index a4ab9b7..efa6f1e 100644 --- a/README.md +++ b/README.md @@ -5,70 +5,430 @@ [![Node: >=22.0.0](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)](package.json) [![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg)](https://pnpm.io/) -> **Note** +> **Disclaimer** > -> _This repository represents an example of using a Chainlink product or service. It is provided to help you understand how to interact with Chainlink's systems so that you can integrate them into your own. This template is provided "AS IS" without warranties of any kind, has not been audited, and may be missing key checks or error handling to make the usage of the product more clear. You must thoroughly test and simulate all transactions offchain, validate functionality on testnet environments, and conduct comprehensive security reviews before deploying to mainnet or any production environment._ - -A tool to generate calldata for TokenPool contract interactions, including token and pool deployment, and chain updates. Supports both raw calldata and Safe Transaction Builder JSON formats with multi-destination-chain support. - -## Features - -- **Multi Destination Chain Support**: Supports EVM --> EVM and EVM --> SVM chains. Move VM is TODO. -- **Cross-Chain Token Pools**: Configure token pools across different blockchain architectures -- **Multiple Output Formats**: Raw calldata or Safe Transaction Builder JSON +> _This repository contains example code of how a Chainlink product or service can be used. It is provided solely to demonstrate a potential integration approach and is not intended for production. This repository is provided "AS IS" without warranties of any kind, has not been audited, may be incomplete, and may be missing key checks or error handling mechanisms. You are solely responsible for testing and simulating all code and transactions, validating functionality on testnet environments, and conducting comprehensive security, technical, and engineering reviews before deploying anything to any mainnet or production environments. SmartContract Chainlink Limited SEZC (“Chainlink Labs”) disclaims all liability for any loss or damage arising from or related to your use of or reliance on this repository. Chainlink Labs does not represent or warrant that the repository will be uninterrupted, available at any particular time, or error-free._ + +A CLI tool to generate calldata for CCIP TokenPool contract interactions. Supports deploying tokens and pools across EVM chains, configuring cross-chain settings, and managing token permissions. Outputs raw calldata or Safe Transaction Builder JSON. + +## Table of Contents + +- [What This Tool Does](#what-this-tool-does) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Input File Reference](#input-file-reference) +- [Quick Start: Deploy Cross-Chain Token](#quick-start-deploy-cross-chain-token-base-sepolia--ethereum-sepolia) +- [Finding Chain Selectors and Factory Addresses](#finding-chain-selectors-and-factory-addresses) +- [Commands](#commands) + - [generate-token-deployment](#generate-token-deployment) + - [generate-pool-deployment](#generate-pool-deployment) + - [generate-chain-update](#generate-chain-update) + - [generate-accept-ownership](#generate-accept-ownership) + - [generate-grant-roles](#generate-grant-roles) + - [generate-mint](#generate-mint) + - [generate-allow-list-updates](#generate-allow-list-updates) + - [generate-rate-limiter-config](#generate-rate-limiter-config) +- [Advanced Configuration](#advanced-configuration) + - [Pool Types](#pool-types) + - [Rate Limiter Configuration](#rate-limiter-configuration) + - [Allow List Configuration](#allow-list-configuration) +- [Understanding CREATE2](#understanding-create2) +- [Output Formats](#output-formats) +- [Troubleshooting](#troubleshooting) +- [Development](#development) +- [Project Structure](#project-structure) +- [Additional Resources](#additional-resources) + +## What This Tool Does + +This CLI tool generates transactions in JSON format compatible with Safe wallet UI for Cross-Chain Token (CCT) operations. Projects using Safe multisig can upload the generated JSON files directly into their Safe Transaction Builder interface to execute operations securely. + +**Primary Use Case**: Managing Cross-Chain Tokens (CCT) through Safe multisig wallets + +**Supported Operations**: + +- **Deploy tokens and pools** through TokenPoolFactory (uses CREATE2 for deterministic addresses) +- **Register tokens** in the Token Admin Registry +- **Configure token pools** for cross-chain transfers: + - Add/remove remote chain connections + - Configure rate limiters (transfer volume limits) + - Manage sender allow lists +- **Grant/revoke roles** (mint and burn permissions) +- **Mint tokens** for testing and operations + +**Output Format**: Generates Safe Transaction Builder JSON files that can be imported directly into the Safe UI, or raw calldata for direct contract interaction. ## Prerequisites -- Node.js >= 20.0.0 -- pnpm - `npm install pnpm` +- Node.js >= 22.0.0 +- pnpm: `npm install -g pnpm` +- A Safe multisig wallet (for safe-json format) +- Chain selectors and factory addresses for your chains ([see reference](#finding-chain-selectors-and-factory-addresses)) ## Installation ```bash # Clone the repository -git clone +git clone https://github.com/smartcontractkit/token-pools-calldata.git +cd token-pools-calldata # Install dependencies pnpm install + +# Verify installation +pnpm start --help ``` -## Project Structure +## Input File Reference + +All input files are in JSON format. Ready-to-use examples are in the `examples/` directory. + +### Token Deployment (`examples/token-and-pool-deployment.json`) + +| Field | Type | Description | +| ------------------ | ------ | --------------------------------------------------------------- | +| `name` | string | Token name displayed in wallets and explorers | +| `symbol` | string | Token ticker symbol (e.g., `ETH`, `USDC`) | +| `decimals` | number | Decimal places. Use `18` for standard tokens, `6` for USDC-like | +| `maxSupply` | string | Maximum supply in wei (see conversion below) | +| `preMint` | string | Tokens minted to deployer on deployment, in wei | +| `remoteTokenPools` | array | Remote chain configs. Leave as `[]` for initial deployment | + +**Wei Conversion**: Token amounts are specified in wei (smallest unit). Multiply the human-readable amount by `10^decimals`: + +| Human Amount | Decimals | Wei Value | +| ---------------- | -------- | ----------------------------- | +| 1,000,000 tokens | 18 | `"1000000000000000000000000"` | +| 100,000 tokens | 18 | `"100000000000000000000000"` | +| 1,000 tokens | 18 | `"1000000000000000000000"` | +| 1,000,000 tokens | 6 | `"1000000000000"` | + +### Chain Update (`examples/chain-update.json`) + +Format: `[chainsToRemove, chainsToAdd]` + +| Field | Type | Description | +| --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `remoteChainSelector` | string | CCIP chain selector (uint64). See [Finding Chain Selectors](#finding-chain-selectors-and-factory-addresses) | +| `remotePoolAddresses` | string[] | Pool addresses on remote chain | +| `remoteTokenAddress` | string | Token address on remote chain | +| `outboundRateLimiterConfig` | object | Rate limits for tokens sent TO remote chain | +| `inboundRateLimiterConfig` | object | Rate limits for tokens received FROM remote chain | +| `remoteChainType` | string | `"evm"` for Ethereum-like chains, `"svm"` for Solana | + +### Rate Limiter Config + +| Field | Type | Description | +| ----------- | ------- | --------------------------------------------- | +| `isEnabled` | boolean | Enable/disable rate limiting | +| `capacity` | string | Maximum tokens transferable at once (wei) | +| `rate` | string | Bucket refill rate in tokens per second (wei) | + +**Capacity/Rate Values** (for 18-decimal tokens): + +| Use Case | Capacity | Rate | Capacity (wei) | Rate (wei) | +| ---------- | -------------- | ---------- | ----------------------------- | --------------------------- | +| Test/Dev | 100,000 tokens | 1,000/sec | `"100000000000000000000000"` | `"1000000000000000000000"` | +| Low Volume | 1M tokens | 10,000/sec | `"1000000000000000000000000"` | `"10000000000000000000000"` | + +## Quick Start: Deploy Cross-Chain Token (Base Sepolia ↔ Ethereum Sepolia) + +This guide deploys a cross-chain token between Base Sepolia and Ethereum Sepolia. + +### Before You Start + +Gather these values - you'll use them in every command: + +| Parameter | Description | Where to find | +| -------------------- | ---------------------------------------------- | ------------- | +| `YOUR_SAFE_ADDRESS` | Your Safe multisig address | Safe UI | +| `YOUR_OWNER_ADDRESS` | Address of the Safe owner signing transactions | Your wallet | + +**Chain Reference** (used throughout this guide): + +| Chain | Chain ID | Chain Selector | Factory Address | +| ---------------- | ---------- | ---------------------- | -------------------------------------------- | +| Base Sepolia | `84532` | `10344971235874465080` | `0xff170aD8f1d86eFAC90CA7a2E1204bA64aC5e0f9` | +| Ethereum Sepolia | `11155111` | `16015286601757825753` | `0xBCf47E9195A225813A629BB7580eDF338c2d8202` | +### Step 1: Deploy Token + Pool on Both Chains + +The example file `examples/token-and-pool-deployment.json` contains a ready-to-use token configuration. To customize, copy and edit the file (see [Input File Reference](#input-file-reference)). + +**Deploy on Base Sepolia:** + +```bash +pnpm start generate-token-deployment \ + -i examples/token-and-pool-deployment.json \ + -d 0xff170aD8f1d86eFAC90CA7a2E1204bA64aC5e0f9 \ + --salt 0x0000000000000000000000000000000000000000000000000000000000000001 \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 84532 \ + -o output/base-deployment.json ``` -. -├── abis/ # Contract ABIs -├── examples/ # Example input files -│ ├── token-deployment.json -│ ├── pool-deployment.json -│ └── chain-update.json -├── src/ -│ ├── generators/ # Calldata generation logic -│ ├── types/ # TypeScript types and validation -│ └── utils/ # Utility functions + +| Flag | Description | +| -------- | ---------------------------------------------------------------------------------------------------------- | +| `-i` | Path to input JSON file with token configuration | +| `-d` | TokenPoolFactory contract address (from Chain Reference table above) | +| `--salt` | 32-byte hex value for deterministic deployment. Use the same salt on both chains for predictable addresses | +| `-f` | Output format: `safe-json` for Safe Transaction Builder, `calldata` for raw hex | +| `-s` | Your Safe multisig address | +| `-w` | Address of the Safe owner signing the transaction | +| `-c` | Chain ID where the transaction will execute | +| `-o` | Output file path | + +**Deploy on Ethereum Sepolia** (same input file, different chain parameters): + +```bash +pnpm start generate-token-deployment \ + -i examples/token-and-pool-deployment.json \ + -d 0xBCf47E9195A225813A629BB7580eDF338c2d8202 \ + --salt 0x0000000000000000000000000000000000000000000000000000000000000001 \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 11155111 \ + -o output/eth-deployment.json +``` + +**Execute and record outputs:** + +1. Import `output/base-deployment.json` into [Safe Transaction Builder](https://app.safe.global) and execute +2. Import `output/eth-deployment.json` into Safe Transaction Builder and execute +3. From the transaction logs, find the two "Created" contract addresses: + - **First** "Created" address → Token contract + - **Second** "Created" address → Token Pool contract + +4. Record the deployed addresses: + +``` +# Save these - you'll need them in Steps 2 and 3 +BASE_TOKEN_ADDRESS=0x... # First "Created" address on Base Sepolia +BASE_POOL_ADDRESS=0x... # Second "Created" address on Base Sepolia +ETH_TOKEN_ADDRESS=0x... # First "Created" address on Ethereum Sepolia +ETH_POOL_ADDRESS=0x... # Second "Created" address on Ethereum Sepolia +``` + +### Step 2: Accept Ownership + +The factory sets your Safe as `pendingOwner` on both the token and pool contracts. You must accept ownership before you can configure cross-chain connections. + +**Generate transactions:** + +```bash +# Base Sepolia: Accept ownership of token +pnpm start generate-accept-ownership \ + -a BASE_TOKEN_ADDRESS \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 84532 \ + -o output/base-token-accept-ownership.json + +# Base Sepolia: Accept ownership of pool +pnpm start generate-accept-ownership \ + -a BASE_POOL_ADDRESS \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 84532 \ + -o output/base-pool-accept-ownership.json + +# Ethereum Sepolia: Accept ownership of token +pnpm start generate-accept-ownership \ + -a ETH_TOKEN_ADDRESS \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 11155111 \ + -o output/eth-token-accept-ownership.json + +# Ethereum Sepolia: Accept ownership of pool +pnpm start generate-accept-ownership \ + -a ETH_POOL_ADDRESS \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 11155111 \ + -o output/eth-pool-accept-ownership.json +``` + +| Flag | Description | +| ---- | ------------------------------------------------------- | +| `-a` | Contract address to accept ownership of (token or pool) | + +**Execute:** + +> **Tip:** To reduce the number of signatures, batch the token and pool transactions per chain. In Safe Transaction Builder, import both JSON files for the same chain, then execute them as a single batched transaction. + +1. **Base Sepolia**: Import `base-token-accept-ownership.json` and `base-pool-accept-ownership.json`, batch and execute +2. **Ethereum Sepolia**: Import `eth-token-accept-ownership.json` and `eth-pool-accept-ownership.json`, batch and execute + +### Step 3: Configure Cross-Chain Connections + +Each pool must know about its counterpart on the other chain. This step uses addresses from **both chains** deployed in Step 1. + +**Create `base-to-eth.json`** - configures the Base Sepolia pool to recognize the Ethereum Sepolia pool: + +```json +[ + [], + [ + { + "remoteChainSelector": "16015286601757825753", + "remotePoolAddresses": ["ETH_POOL_ADDRESS"], + "remoteTokenAddress": "ETH_TOKEN_ADDRESS", + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" + }, + "remoteChainType": "evm" + } + ] +] +``` + +Replace `ETH_POOL_ADDRESS` and `ETH_TOKEN_ADDRESS` with the Ethereum Sepolia addresses from Step 1. + +**Create `eth-to-base.json`** - configures the Ethereum Sepolia pool to recognize the Base Sepolia pool: + +```json +[ + [], + [ + { + "remoteChainSelector": "10344971235874465080", + "remotePoolAddresses": ["BASE_POOL_ADDRESS"], + "remoteTokenAddress": "BASE_TOKEN_ADDRESS", + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" + }, + "remoteChainType": "evm" + } + ] +] +``` + +Replace `BASE_POOL_ADDRESS` and `BASE_TOKEN_ADDRESS` with the Base Sepolia addresses from Step 1. + +**Generate transactions:** + +```bash +# Configure Base Sepolia pool (uses BASE_POOL_ADDRESS from Step 1) +pnpm start generate-chain-update \ + -i base-to-eth.json \ + -p BASE_POOL_ADDRESS \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 84532 \ + -o output/base-chain-update.json + +# Configure Ethereum Sepolia pool (uses ETH_POOL_ADDRESS from Step 1) +pnpm start generate-chain-update \ + -i eth-to-base.json \ + -p ETH_POOL_ADDRESS \ + -f safe-json \ + -s YOUR_SAFE_ADDRESS \ + -w YOUR_OWNER_ADDRESS \ + -c 11155111 \ + -o output/eth-chain-update.json ``` -## Examples +| Flag | Description | +| ---- | -------------------------------------------------------------- | +| `-i` | Path to chain update JSON file | +| `-p` | Pool address to configure (the local pool, not the remote one) | + +**Execute:** Import and execute both JSON files in Safe. + +### Done + +Your cross-chain token is configured. Summary of what you deployed: + +| Chain | Token | Pool | +| ---------------- | -------------------- | ------------------- | +| Base Sepolia | `BASE_TOKEN_ADDRESS` | `BASE_POOL_ADDRESS` | +| Ethereum Sepolia | `ETH_TOKEN_ADDRESS` | `ETH_POOL_ADDRESS` | + +Your Safe received the preMinted tokens (100,000 tCCIP in the example config). To mint additional tokens, use the [`generate-mint`](#generate-mint) command. + +To transfer tokens cross-chain, interact with the CCIP Router contract. See the [CCIP Documentation](https://docs.chain.link/ccip) for instructions. + +## Finding Chain Selectors and Factory Addresses -The `examples/` directory contains ready-to-use JSON templates that mirror the input formats used by the CLI commands. Copy the file that matches your workflow, adjust the addresses and parameters, and pass it through the `-i` flag when running `pnpm start generate-*`. They are the quickest way to validate your setup before wiring in real deployment data. +Chain selectors and TokenPoolFactory addresses are available from the CCIP API: -## Usage +- **Mainnet**: https://docs.chain.link/api/ccip/v1/chains?environment=mainnet +- **Testnet**: https://docs.chain.link/api/ccip/v1/chains?environment=testnet -### Deploy Token and Pool +Navigate to `data > evm > [chainId]` to find: -The tool supports deploying a new token and its associated pool using the TokenPoolFactory contract. You can either: +- `selector` - the chain selector (used in `remoteChainSelector`) +- `tokenPoolFactory` - the factory address (used in `-d` flag) -1. Deploy both token and pool together using `generate-token-deployment` -2. Deploy just a pool for an existing token using `generate-pool-deployment` +**Common Testnet Values:** -#### Token Deployment Input Format +| Chain | Chain ID | Chain Selector | Factory Address | +| ---------------- | -------- | ---------------------- | -------------------------------------------- | +| Ethereum Sepolia | 11155111 | `16015286601757825753` | `0xBCf47E9195A225813A629BB7580eDF338c2d8202` | +| Base Sepolia | 84532 | `10344971235874465080` | `0xff170aD8f1d86eFAC90CA7a2E1204bA64aC5e0f9` | +| Arbitrum Sepolia | 421614 | `3478487238524512106` | Check API | +| Optimism Sepolia | 11155420 | `5224473277236331295` | Check API | -Create a JSON file with the token parameters (e.g., `examples/token-deployment.json`): +## Commands + +### generate-token-deployment + +Deploy a new BurnMintERC20 token and BurnMintTokenPool together using TokenPoolFactory. + +**Usage:** + +```bash +pnpm start generate-token-deployment \ + -i \ + -d \ + --salt <32-byte-hex> \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` + +**Options:** + +- `-i, --input `: Input JSON file (required) +- `-d, --deployer
`: TokenPoolFactory address (required) +- `--salt `: 32-byte salt for CREATE2 (required) +- `-f, --format `: `calldata` or `safe-json` (default: `calldata`) +- `-s, --safe
`: Safe address (required for safe-json) +- `-w, --owner
`: Owner address (required for safe-json) +- `-c, --chain-id `: Chain ID (required for safe-json) +- `-o, --output `: Output file (optional, defaults to stdout) + +**Input JSON** (see `examples/token-and-pool-deployment.json`): ```json { - "name": "Test CCIP Token", - "symbol": "tCCIP", + "name": "My Token", + "symbol": "MTK", "decimals": 18, "maxSupply": "1000000000000000000000000", "preMint": "100000000000000000000000", @@ -76,306 +436,747 @@ Create a JSON file with the token parameters (e.g., `examples/token-deployment.j } ``` -#### Generate Token Deployment Transaction +See [Input File Reference](#input-file-reference) for field descriptions and wei conversion. + +- `remoteTokenPools`: Array of remote chain configurations (optional, see [Advanced Configuration](#advanced-configuration)) + +### generate-pool-deployment + +Deploy a TokenPool for an existing ERC20 token. + +**Usage:** ```bash -# Generate Safe Transaction Builder JSON -pnpm start generate-token-deployment \ - -i examples/token-deployment.json \ +pnpm start generate-pool-deployment \ + -i \ -d \ --salt <32-byte-hex> \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` + +**Input JSON:** + +```json +{ + "token": "0x779877A7B0D9E8603169DdbD7836e478b4624789", + "decimals": 18, + "poolType": "BurnMintTokenPool", + "remoteTokenPools": [] +} +``` + +**Fields:** + +- `token`: Existing token contract address (validated Ethereum address) +- `decimals`: Token decimals (number) +- `poolType`: `"BurnMintTokenPool"` or `"LockReleaseTokenPool"` +- `remoteTokenPools`: Array of remote chain configurations (optional) + +**Pool Types:** + +| Pool Type | When to Use | Requirements | +| -------------------- | --------------------------------------------------------------- | -------------------------------------- | +| BurnMintTokenPool | Token should be burned on source chain, minted on destination | Token must implement burn/mint methods | +| LockReleaseTokenPool | Token should be locked on source chain, released on destination | Standard ERC20 token | + +### generate-chain-update + +Configure cross-chain connections for a TokenPool. Can add new chains, remove existing chains, or both in a single transaction. + +**Usage:** + +```bash +pnpm start generate-chain-update \ + -i \ + -p \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` + +**Options:** + +- `-i, --input `: Input JSON file (required) +- `-p, --token-pool
`: TokenPool address (optional, defaults to placeholder) +- `-f, --format `: `calldata` or `safe-json` (default: `calldata`) +- `-s, --safe
`: Safe address (required for safe-json) +- `-w, --owner
`: Owner address (required for safe-json) +- `-c, --chain-id `: Chain ID (required for safe-json) +- `-o, --output `: Output file (optional, defaults to stdout) + +**Input JSON** (see `examples/chain-update.json`): + +```json +[ + ["12532609583862916517"], + [ + { + "remoteChainSelector": "16015286601757825753", + "remotePoolAddresses": ["0x779877A7B0D9E8603169DdbD7836e478b4624789"], + "remoteTokenAddress": "0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05", + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" + }, + "remoteChainType": "evm" + } + ] +] +``` + +**Format**: `[chainsToRemove, chainsToAdd]` + +- **First array**: Chain selectors to remove (empty `[]` if only adding) +- **Second array**: Chain configurations to add (empty `[]` if only removing) + +See [Input File Reference](#input-file-reference) for field descriptions. Key fields: + +- `remoteChainSelector`: Chain selector (find at [CCIP API](#finding-chain-selectors-and-factory-addresses)) +- `remotePoolAddresses`: Array of pool addresses on remote chain +- `remoteTokenAddress`: Token address on remote chain +- `remoteChainType`: `"evm"` for Ethereum-like chains, `"svm"` for Solana + +**Rate Limiter Configuration:** + +| Field | Type | Description | +| --------- | ------- | --------------------------------------------- | +| isEnabled | boolean | Enable/disable rate limiting | +| capacity | string | Maximum tokens transferable at once (wei) | +| rate | string | Bucket refill rate in tokens per second (wei) | + +**Recommended Rate Limiter Values** (for 18-decimal tokens): + +| Use Case | Capacity | Rate | Capacity (wei) | Rate (wei) | +| ------------- | ----------- | ------------- | ------------------------------- | ----------------------------- | +| Test/Dev | 100,000 | 1,000/sec | `"100000000000000000000000"` | `"1000000000000000000000"` | +| Low Volume | 1,000,000 | 10,000/sec | `"1000000000000000000000000"` | `"10000000000000000000000"` | +| Medium Volume | 10,000,000 | 100,000/sec | `"10000000000000000000000000"` | `"100000000000000000000000"` | +| High Volume | 100,000,000 | 1,000,000/sec | `"100000000000000000000000000"` | `"1000000000000000000000000"` | + +### generate-mint + +Generate a mint transaction for BurnMintERC20 tokens. Caller must have minter role. + +**Usage:** + +```bash +pnpm start generate-mint \ + -t \ + -r \ + -a \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` + +**Options:** + +- `-t, --token
`: Token contract address (required) +- `-r, --receiver
`: Receiver address (required) +- `-a, --amount `: Amount to mint (string, required) +- `-f, --format `: `calldata` or `safe-json` (default: `calldata`) +- `-s, --safe
`: Safe address (required for safe-json) +- `-w, --owner
`: Owner address (required for safe-json) +- `-c, --chain-id `: Chain ID (required for safe-json) +- `-o, --output `: Output file (optional, defaults to stdout) + +**Example:** + +```bash +# Mint 1000 tokens (with 18 decimals) +pnpm start generate-mint \ + -t 0x779877A7B0D9E8603169DdbD7836e478b4624789 \ + -r 0x1234567890123456789012345678901234567890 \ + -a 1000000000000000000000 \ -f safe-json \ - -s \ - -w \ - -c \ - -o output/token-deployment.json + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 +``` -# Example with actual values: -pnpm start generate-token-deployment \ - -i examples/token-deployment.json \ - -d 0x17d8a409fe2cef2d3808bcb61f14abeffc28876e \ - --salt 0x0000000000000000000000000000000000000000000000000000000123456789 \ +### generate-accept-ownership + +Accept ownership of a contract using the two-step ownership transfer pattern. + +> **When to use:** After deploying a token and pool via `generate-token-deployment`, the TokenPoolFactory sets your Safe as `pendingOwner`. You must accept ownership before calling owner-only functions like `applyChainUpdates`. + +**Usage:** + +```bash +pnpm start generate-accept-ownership \ + -a \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` + +**Options:** + +- `-a, --address
`: Contract address to accept ownership of (required) +- `-f, --format `: `calldata` or `safe-json` (default: `calldata`) +- `-s, --safe
`: Safe address (required for safe-json) +- `-w, --owner
`: Owner address (required for safe-json) +- `-c, --chain-id `: Chain ID (required for safe-json) +- `-o, --output `: Output file (optional, defaults to stdout) + +**Examples:** + +```bash +# Accept ownership of a token +pnpm start generate-accept-ownership \ + -a 0x779877A7B0D9E8603169DdbD7836e478b4624789 \ + -f safe-json \ + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 \ + -o output/accept-token-ownership.json + +# Accept ownership of a pool +pnpm start generate-accept-ownership \ + -a 0x1234567890123456789012345678901234567890 \ -f safe-json \ - -s 0x5419c6d83473d1c653e7b51e8568fafedce94f01 \ - -w 0x0000000000000000000000000000000000000000 \ - -c 1 \ - -o output/token-deployment.json + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 \ + -o output/accept-pool-ownership.json ``` -Command options: +### generate-grant-roles -- `-i, --input `: Path to input JSON file (required) -- `-d, --deployer
`: TokenPoolFactory contract address (required) -- `--salt `: Salt for CREATE2 deployment (required, must be 32 bytes) -- `-f, --format `: Output format: "calldata" or "safe-json" (optional, defaults to "calldata") -- `-s, --safe
`: Safe address for safe-json format (required for safe-json) -- `-w, --owner
`: Owner address for safe-json format (required for safe-json) -- `-c, --chain-id `: Chain ID for safe-json format (required for safe-json) -- `-o, --output `: Path to output file (optional, defaults to stdout) +Grant or revoke mint and/or burn permissions to/from a TokenPool. + +> **When to use:** This command is only needed when deploying a pool for an **existing token** (via `generate-pool-deployment`). When deploying a new token + pool together (via `generate-token-deployment`), the TokenPoolFactory automatically grants mint/burn roles to the pool. + +**Usage:** + +```bash +pnpm start generate-grant-roles \ + -t \ + -p \ + [--action grant|revoke] \ + [--role-type mint|burn|both] \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` + +**Options:** + +- `-t, --token
`: Token contract address (required) +- `-p, --pool
`: Pool contract address (required) +- `--action `: `grant` or `revoke` (default: `grant`) +- `--role-type `: `mint`, `burn`, or `both` (default: `both`) +- `-f, --format `: `calldata` or `safe-json` (default: `calldata`) +- `-s, --safe
`: Safe address (required for safe-json) +- `-w, --owner
`: Owner address (required for safe-json) +- `-c, --chain-id `: Chain ID (required for safe-json) +- `-o, --output `: Output file (optional, defaults to stdout) + +**Granting Roles:** + +```bash +# Grant both mint and burn roles (1 transaction) +pnpm start generate-grant-roles \ + -t 0x779877A7B0D9E8603169DdbD7836e478b4624789 \ + -p 0x1234567890123456789012345678901234567890 \ + --action grant \ + --role-type both \ + -f safe-json \ + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 + +# Grant mint role only +pnpm start generate-grant-roles \ + -t 0x779877A7B0D9E8603169DdbD7836e478b4624789 \ + -p 0x1234567890123456789012345678901234567890 \ + --action grant \ + --role-type mint \ + -f safe-json \ + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 +``` + +**Revoking Roles:** + +```bash +# Revoke both mint and burn roles (2 transactions - executed atomically in Safe) +pnpm start generate-grant-roles \ + -t 0x779877A7B0D9E8603169DdbD7836e478b4624789 \ + -p 0x1234567890123456789012345678901234567890 \ + --action revoke \ + --role-type both \ + -f safe-json \ + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 + +# Revoke mint role only +pnpm start generate-grant-roles \ + -t 0x779877A7B0D9E8603169DdbD7836e478b4624789 \ + -p 0x1234567890123456789012345678901234567890 \ + --action revoke \ + --role-type mint \ + -f safe-json \ + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 + +# Revoke burn role only +pnpm start generate-grant-roles \ + -t 0x779877A7B0D9E8603169DdbD7836e478b4624789 \ + -p 0x1234567890123456789012345678901234567890 \ + --action revoke \ + --role-type burn \ + -f safe-json \ + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 +``` + +**Important Notes:** + +- **Revoking both roles**: When using `--action revoke --role-type both`, the tool generates **TWO transactions** that will be executed atomically in Safe: + 1. `revokeMintRole(pool)` - Removes minting permission + 2. `revokeBurnRole(pool)` - Removes burning permission + + This is because the BurnMintERC20 contract provides separate revoke functions but no combined `revokeMintAndBurnRoles()` function (unlike grant operations which have `grantMintAndBurnRoles()`). + +- **Backward compatibility**: The `--action` flag defaults to `grant`, so existing scripts continue to work without modification. + +### generate-allow-list-updates + +Update the sender allow list for a TokenPool. The allow list restricts which addresses can initiate cross-chain transfers through the pool. + +**Usage:** + +```bash +pnpm start generate-allow-list-updates \ + -i \ + -p \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` + +**Options:** -#### Pool Deployment Input Format +- `-i, --input `: Path to input JSON file (required) +- `-p, --pool
`: Token pool contract address (required) +- `-f, --format `: Output format - `calldata` or `safe-json` (default: `calldata`) +- `-s, --safe
`: Safe address (required for safe-json) +- `-w, --owner
`: Owner address (required for safe-json) +- `-c, --chain-id `: Chain ID (required for safe-json) +- `-o, --output `: Output file (optional, defaults to stdout) -For deploying a pool for an existing token, create a JSON file (e.g., `examples/pool-deployment.json`). The input matches the parameters of the `deployTokenPoolWithExistingToken` function: +**Input JSON** (see `examples/allow-list-updates.json`): ```json { - "token": "0x779877A7B0D9E8603169DdbD7836e478b4624789", - "decimals": 18, - "remoteTokenPools": [], - "poolType": "BurnMintTokenPool" + "removes": ["0x1234567890123456789012345678901234567890"], + "adds": [ + "0x779877A7B0D9E8603169DdbD7836e478b4624789", + "0xa469F39796Cad956bE2E51117693880dB3E6438d" + ] } ``` -The input JSON requires: +**Parameters:** -- `token`: Address of the existing token (required) -- `decimals`: Token decimals (required, 0-255) -- `remoteTokenPools`: Array of remote token pool configurations (optional, defaults to empty array) -- `poolType`: Either "BurnMintTokenPool" or "LockReleaseTokenPool" (required) +- `removes`: Addresses to remove from allow list (optional, defaults to `[]`) +- `adds`: Addresses to add to allow list (optional, defaults to `[]`) -#### Generate Pool Deployment Transaction +**Example:** ```bash -# Generate Safe Transaction Builder JSON -pnpm start generate-pool-deployment \ - -i examples/pool-deployment.json \ - -d \ - --salt <32-byte-hex> \ +pnpm start generate-allow-list-updates \ + -i examples/allow-list-updates.json \ + -p 0x1234567890123456789012345678901234567890 \ -f safe-json \ - -s \ - -w \ - -c \ - -o output/pool-deployment.json + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 \ + -o output/allow-list-updates.json ``` +### generate-rate-limiter-config + +Update rate limiter configuration for a specific remote chain on a TokenPool. + +**Usage:** + ```bash -# Example with actual values: +pnpm start generate-rate-limiter-config \ + -i \ + -p \ + [-f calldata|safe-json] \ + [-s ] \ + [-w ] \ + [-c ] \ + [-o ] +``` -pnpm start generate-pool-deployment \ - -i examples/pool-deployment.json \ - -d 0x17d8a409fe2cef2d3808bcb61f14abeffc28876e \ - --salt 0x0000000000000000000000000000000000000000000000000000000123456789 \ +**Options:** + +- `-i, --input `: Path to input JSON file (required) +- `-p, --pool
`: Token pool contract address (required) +- `-f, --format `: Output format - `calldata` or `safe-json` (default: `calldata`) +- `-s, --safe
`: Safe address (required for safe-json) +- `-w, --owner
`: Owner address (required for safe-json) +- `-c, --chain-id `: Chain ID (required for safe-json) +- `-o, --output `: Output file (optional, defaults to stdout) + +**Input JSON** (see `examples/rate-limiter-config.json`): + +```json +{ + "remoteChainSelector": "3478487238524512106", + "outboundConfig": { + "isEnabled": true, + "capacity": "1000000000000000000000", + "rate": "100000000000000000000" + }, + "inboundConfig": { + "isEnabled": true, + "capacity": "1000000000000000000000", + "rate": "100000000000000000000" + } +} +``` + +**Parameters:** + +- `remoteChainSelector`: Chain selector (find at [CCIP API](#finding-chain-selectors-and-factory-addresses)) +- `outboundConfig`: Rate limiter for tokens sent TO remote chain +- `inboundConfig`: Rate limiter for tokens received FROM remote chain + +**Rate Limiter Configuration:** + +| Field | Type | Description | +| --------- | ------- | --------------------------------------------- | +| isEnabled | boolean | Enable/disable rate limiting | +| capacity | string | Maximum tokens in bucket (wei) | +| rate | string | Bucket refill rate in tokens per second (wei) | + +See [Input File Reference](#input-file-reference) for wei conversion and recommended values. + +**Example:** + +```bash +pnpm start generate-rate-limiter-config \ + -i examples/rate-limiter-config.json \ + -p 0x1234567890123456789012345678901234567890 \ -f safe-json \ - -s 0x5419c6d83473d1c653e7b51e8568fafedce94f01 \ - -w 0x0000000000000000000000000000000000000000 \ - -c 1 \ - -o output/pool-deployment.json + -s 0xYourSafe \ + -w 0xYourOwner \ + -c 84532 \ + -o output/rate-limiter-config.json ``` -### Generate Chain Update Calldata +## Advanced Configuration -The tool supports updating chain configurations for token pools, allowing you to: +### Multiple Pool Addresses Per Remote Chain -1. Remove existing chain configurations -2. Add new chain configurations with rate limiters +You can configure multiple pool addresses for a single remote chain. This is useful when: -#### Chain Update Input Format +- Multiple pools manage the same token on the remote chain +- Gradual migration between pool versions +- Multi-token pool architectures -Create a JSON file with the chain update parameters (e.g., `examples/chain-update.json`): +**Example:** + +```json +{ + "remoteChainSelector": "16015286601757825753", + "remotePoolAddresses": [ + "0x779877A7B0D9E8603169DdbD7836e478b4624789", + "0x1234567890123456789012345678901234567890", + "0xa469F39796Cad956bE2E51117693880dB3E6438d" + ], + "remoteTokenAddress": "0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05", + "outboundRateLimiterConfig": { ... }, + "inboundRateLimiterConfig": { ... }, + "remoteChainType": "evm" +} +``` + +### EVM ↔ SVM (Solana) Cross-Chain + +Configure cross-chain connections between EVM and Solana chains. + +**Address Formats:** + +- **EVM**: Standard 20-byte Ethereum addresses (e.g., `0x779877A7B0D9E8603169DdbD7836e478b4624789`) +- **SVM**: 32-byte Solana public keys in base58 format (e.g., `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`) + +**Example - Ethereum → Solana:** ```json [ [], [ { - "remoteChainSelector": "12532609583862916517", - "remotePoolAddresses": [ - "0x779877A7B0D9E8603169DdbD7836e478b4624789", - "0x1234567890123456789012345678901234567890" - ], - "remoteTokenAddress": "0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05", + "remoteChainSelector": "SOLANA_CHAIN_SELECTOR", + "remotePoolAddresses": ["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"], + "remoteTokenAddress": "So11111111111111111111111111111111111111112", "outboundRateLimiterConfig": { "isEnabled": true, - "capacity": "1000000", - "rate": "100000" + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" }, "inboundRateLimiterConfig": { "isEnabled": true, - "capacity": "1000000", - "rate": "100000" + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" }, - "remoteChainType": "evm" // could be "svm" etc. + "remoteChainType": "svm" } ] ] ``` -##### Chain Update Fields +**Note**: Set `remoteChainType` to `"svm"` for Solana chains. The tool will automatically encode Solana addresses as `bytes32`. -Each chain update object requires: +### Batch Operations -- `remoteChainSelector`: Unique identifier for the remote chain. -- `remotePoolAddresses`: Array of pool addresses on the remote chain. -- `remoteTokenAddress`: Token address on the remote chain. -- `outboundRateLimiterConfig`: Rate limiter for outbound transfers. -- `inboundRateLimiterConfig`: Rate limiter for inbound transfers. -- `remoteChainType`: Chain type: `"evm"` or `"svm"`. As per one of the enum set out in `/src/types/chainUpdate.ts`. +Add and remove chains in a single transaction: -##### Address Formats by Chain Type +```json +[ + ["12532609583862916517", "3478487238524512106"], + [ + { + "remoteChainSelector": "16015286601757825753", + "remotePoolAddresses": ["0x779877A7B0D9E8603169DdbD7836e478b4624789"], + "remoteTokenAddress": "0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05", + "outboundRateLimiterConfig": { ... }, + "inboundRateLimiterConfig": { ... }, + "remoteChainType": "evm" + } + ] +] +``` -- **EVM**: Standard Ethereum addresses (20 bytes, hex format). - - Example: `"0x779877A7B0D9E8603169DdbD7836e478b4624789"` -- **SVM**: Solana public keys (32 bytes, base58 format). - - Example: `"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"` +This removes chains `12532609583862916517` and `3478487238524512106` while adding chain `16015286601757825753`. -#### Generate Chain Update Transaction +### Remote Token Pools During Deployment -```bash -# Generate Safe Transaction Builder JSON -pnpm start generate-chain-update \ - -i examples/chain-update.json \ - -p \ - -f safe-json \ - -s \ - -w \ - -c \ - -o output/chain-update.json +Configure remote chains during initial token/pool deployment: + +See `examples/token-deployment-with-remote.json` for a complete example with remote chain configuration. + +## Understanding CREATE2 + +This tool uses CREATE2 for deterministic contract deployment. Key concepts: + +**Salt**: A 32-byte value that determines the deployment address. The same salt with the same code and deployer always produces the same address. + +**Salt Modification**: The TokenPoolFactory modifies your salt by hashing it with the sender address: -# Example with actual values: -pnpm start generate-chain-update \ - -i examples/chain-update.json \ - -p 0x1234567890123456789012345678901234567890 \ - -f safe-json \ - -s 0xbF6512B1bBEeC3a673Feff43C0A182C2b28DFD9f \ - -w 0x0000000000000000000000000000000000000000 \ - -c 11155111 \ - -o output/chain-update.json +``` +modifiedSalt = keccak256(abi.encodePacked(salt, msg.sender)) ``` -Command options: +This means the same salt produces different addresses for different senders. -- `-i, --input `: Path to input JSON file (required) -- `-p, --token-pool
`: Token Pool contract address (required for safe-json) -- `-f, --format `: Output format: "calldata" or "safe-json" (optional, defaults to "calldata") -- `-s, --safe
`: Safe address for safe-json format (required for safe-json) -- `-w, --owner
`: Owner address for safe-json format (required for safe-json) -- `-c, --chain-id `: Chain ID for safe-json format (required for safe-json) -- `-o, --output `: Path to output file (optional, defaults to stdout) +**Address Computation**: The final address is computed as: + +``` +address = keccak256(0xff, deployer, modifiedSalt, keccak256(initCode)) +``` + +The tool automatically computes and logs the deterministic address before deployment. -### Command Options +**Why This Matters**: You can predict deployment addresses before executing transactions. This is useful for: -- `--input `: Path to input JSON file (required) -- `--output `: Path to output file (optional, defaults to stdout) -- `--format `: Output format: "calldata" or "safe-json" (optional, defaults to "calldata") -- `--safe
`: Safe address for safe-json format (optional, defaults to "--SAFE--") -- `--owner
`: Owner address for safe-json format (optional, defaults to "--OWNER--") -- `--chain-id `: Chain ID for safe-json format (required for safe-json) -- `--token-pool
`: Token Pool contract address (optional, defaults to "0xYOUR_POOL_ADDRESS") +- Pre-configuring contracts with addresses +- Coordinating multi-chain deployments +- Verifying deployments -### Output Formats +## Output Formats -#### Raw Calldata +### Raw Calldata -Outputs the encoded function calldata as a hex string. +Hex-encoded function call data. Use with: -#### Safe Transaction Builder JSON +- Web3 libraries (ethers.js, web3.js) +- Block explorers (Etherscan) +- Hardware wallets +- Direct contract interaction tools -Outputs a JSON file compatible with the Safe Transaction Builder format: +**Example:** + +``` +0x4a792d70000000000000000000000000000000000000000000000000000000000000... +``` + +### Safe Transaction Builder JSON + +Structured JSON compatible with Safe Transaction Builder. Includes: + +- Transaction metadata (chain ID, timestamps) +- Safe and owner addresses +- Contract method signatures with full type information +- Human-readable descriptions + +**Example:** ```json { "version": "1.0", - "chainId": "11155111", + "chainId": "84532", "createdAt": 1234567890, "meta": { - "name": "Token Pool Chain Updates", - "description": "Apply chain updates to the Token Pool contract", + "name": "Token and Pool Factory Deployment - My Token", + "description": "Deploy My Token (MTK) token and associated pool using factory", "txBuilderVersion": "1.18.0", - "createdFromSafeAddress": "0xYourSafeAddress", - "createdFromOwnerAddress": "0xOwnerAddress" + "createdFromSafeAddress": "0xYourSafe", + "createdFromOwnerAddress": "0xYourOwner" }, - "transactions": [ - { - "to": "0xYOUR_POOL_ADDRESS", - "value": "0", - "data": "0x...", - "contractMethod": { - "inputs": [ - { - "name": "remoteChainSelectorsToRemove", - "type": "uint64[]", - "internalType": "uint64[]" - }, - { - "name": "chainsToAdd", - "type": "tuple[]", - "internalType": "struct TokenPool.ChainUpdateStruct[]" - } - ], - "name": "applyChainUpdates", - "payable": false - }, - "contractInputsValues": null - } - ] + "transactions": [...] } ``` -## Development +Import this file directly into Safe Transaction Builder. -```bash -# Build the project -pnpm build +## Troubleshooting -# Run linter -pnpm lint:check +### "Invalid token address" or "Invalid pool address" -# Fix linting issues -pnpm lint:fix +**Cause**: Address format is incorrect. -# Format code -pnpm format:fix +**Solution**: Ensure addresses: -# Check code formatting -pnpm format:check +- Start with `0x` +- Are 42 characters long (20 bytes in hex) +- Use valid hex characters (0-9, a-f) + +### "Salt must be a 32-byte hex string" + +**Cause**: Salt is not exactly 32 bytes. + +**Solution**: Salt must be 66 characters total (`0x` + 64 hex characters). Example: + +``` +0x0000000000000000000000000000000000000000000000000000000000000001 ``` -## Testing +### "chainId, safe, and owner are required for Safe Transaction Builder JSON format" -The project includes comprehensive unit tests to ensure code quality and prevent regressions. +**Cause**: Missing required options for safe-json format. + +**Solution**: Add all three options: ```bash -# Run all tests -pnpm test +-s YOUR_SAFE_ADDRESS -w YOUR_OWNER_ADDRESS -c CHAIN_ID +``` -# Run tests in watch mode (re-run on file changes) -pnpm test:watch +### "Failed to parse or validate input JSON" -# Run tests with coverage report -pnpm test:coverage +**Cause**: Input JSON doesn't match expected schema. + +**Solution**: Check that: + +- JSON is valid (use `jq` to validate) +- All required fields are present +- Field types match (strings, numbers, booleans) +- Addresses are valid +- Amounts are strings (not numbers) + +### "SenderNotMinter" error when minting + +**Cause**: Caller doesn't have minter role. + +**Solution**: Grant minter role first: + +```bash +pnpm start generate-grant-roles \ + -t TOKEN_ADDRESS \ + -p POOL_OR_MINTER_ADDRESS \ + --role-type mint ``` -**CI Integration:** All tests are automatically executed on every pull request and push to the main branch via GitHub Actions. The CI workflow ensures that: -- All tests pass before code can be merged -- Code coverage is generated and available as artifacts -- No regressions are introduced +### Rate limiter values too large + +**Cause**: Capacity or rate exceeds uint128 max value. -**Viewing Coverage Reports:** -After running `pnpm test:coverage`, open `coverage/lcov-report/index.html` in your browser to view a detailed HTML coverage report. +**Solution**: Values must be ≤ `340282366920938463463374607431768211455`. For reference: -## Type Generation +- 100 million tokens (18 decimals): `100000000000000000000000000` +- This is well within uint128 limits -The project uses TypeChain to generate TypeScript types from contract ABIs: +## Development ```bash +# Build +pnpm build + +# Lint +pnpm lint:check +pnpm lint:fix + +# Format +pnpm format:check +pnpm format:fix + +# Test +pnpm test +pnpm test:watch +pnpm test:coverage + # Generate types from ABIs pnpm typechain ``` -## Error Handling - -The tool validates input JSON and provides detailed error messages for: - -- Invalid JSON format -- Invalid EVM or SVM addresses (for EVM chains) -- Invalid rate limiter configurations -- Missing required fields -- Missing required parameters for Safe Transaction Builder JSON +## Project Structure -## Output +``` +. +├── abis/ # Contract ABIs +├── examples/ # Example input JSON files +│ ├── token-and-pool-deployment.json +│ ├── token-deployment-with-remote.json +│ ├── pool-deployment.json +│ ├── chain-update.json +│ ├── allow-list-updates.json +│ ├── rate-limiter-config.json +│ ├── grant-roles.json +│ ├── revoke-roles.json +│ └── mint.json +├── src/ +│ ├── cli.ts # CLI entry point +│ ├── constants/ # Bytecodes and constants +│ ├── generators/ # Transaction generators +│ ├── types/ # TypeScript types and Zod schemas +│ ├── typechain/ # Generated contract types +│ └── utils/ # Utility functions +└── output/ # Generated transaction files +``` -The tool can generate: +## Additional Resources -- Raw calldata for direct contract interaction -- Safe Transaction Builder JSON for use with Safe Transaction Builder -- Output can be written to stdout or a file +- [CCIP Documentation](https://docs.chain.link/ccip) +- [TokenPoolFactory Contract](https://docs.chain.link/ccip/architecture#tokenpoolFactory) +- [Safe Transaction Builder](https://help.safe.global/en/articles/40841-transaction-builder) +- [CREATE2 Explanation](https://eips.ethereum.org/EIPS/eip-1014) diff --git a/eslint.config.js b/eslint.config.js index 7a2395f..985432e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -78,7 +78,8 @@ module.exports = [ }, }, - // Configuration for test files + // Configuration for test files - Following FAANG best practices + // Tests have different type safety requirements than production code { files: ['**/*.test.ts', '**/*.spec.ts', '**/test/**/*.ts'], @@ -109,6 +110,7 @@ module.exports = [ beforeAll: 'readonly', afterAll: 'readonly', jest: 'readonly', + fail: 'readonly', }, }, @@ -126,22 +128,26 @@ module.exports = [ ...prettier.rules, 'prettier/prettier': 'error', - // Custom TypeScript-specific rules - '@typescript-eslint/explicit-function-return-type': 'error', + // Rules turned OFF for test files (incompatible with testing patterns) + '@typescript-eslint/unbound-method': 'off', // Jest mocks are unbound by design + '@typescript-eslint/only-throw-error': 'off', // Tests verify non-Error throw handling + '@typescript-eslint/require-await': 'off', // Async test helpers often don't await + '@typescript-eslint/explicit-function-return-type': 'off', // Test arrow functions are self-documenting + + // Rules downgraded to WARN for test files (useful but not blocking) + '@typescript-eslint/no-explicit-any': 'warn', // Relax from error to warn + '@typescript-eslint/no-unsafe-assignment': 'warn', // Keep as warn + '@typescript-eslint/no-unsafe-member-access': 'warn', // Keep as warn + '@typescript-eslint/no-unsafe-call': 'warn', // Keep as warn + '@typescript-eslint/no-unsafe-return': 'warn', // Downgrade from error to warn + '@typescript-eslint/no-unsafe-argument': 'warn', // Add for consistency + + // Standard test file overrides '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' }, ], - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unsafe-assignment': 'warn', - '@typescript-eslint/no-unsafe-member-access': 'warn', - '@typescript-eslint/no-unsafe-call': 'warn', - '@typescript-eslint/no-unsafe-return': 'error', - - // Allow empty interfaces in tests '@typescript-eslint/no-empty-object-type': 'off', - - // Disable no-undef in test files since Jest globals are defined 'no-undef': 'off', }, }, diff --git a/examples/allow-list-updates.json b/examples/allow-list-updates.json new file mode 100644 index 0000000..db7442e --- /dev/null +++ b/examples/allow-list-updates.json @@ -0,0 +1,7 @@ +{ + "removes": [], + "adds": [ + "0x779877A7B0D9E8603169DdbD7836e478b4624789", + "0xa469F39796Cad956bE2E51117693880dB3E6438d" + ] +} diff --git a/examples/chain-update.json b/examples/chain-update.json index 6fe2769..6006aa9 100644 --- a/examples/chain-update.json +++ b/examples/chain-update.json @@ -33,7 +33,8 @@ "isEnabled": false, "capacity": "0", "rate": "0" - } + }, + "remoteChainType": "evm" } ] ] diff --git a/examples/grant-roles.json b/examples/grant-roles.json new file mode 100644 index 0000000..7d6e76b --- /dev/null +++ b/examples/grant-roles.json @@ -0,0 +1,4 @@ +{ + "pool": "0x1234567890123456789012345678901234567890", + "roleType": "both" +} diff --git a/examples/mint.json b/examples/mint.json new file mode 100644 index 0000000..555a59e --- /dev/null +++ b/examples/mint.json @@ -0,0 +1,4 @@ +{ + "receiver": "0x1234567890123456789012345678901234567890", + "amount": "1000000000000000000000" +} diff --git a/examples/rate-limiter-config.json b/examples/rate-limiter-config.json new file mode 100644 index 0000000..3ea2bc1 --- /dev/null +++ b/examples/rate-limiter-config.json @@ -0,0 +1,13 @@ +{ + "remoteChainSelector": "3478487238524512106", + "outboundConfig": { + "isEnabled": true, + "capacity": "1000000000000000000000", + "rate": "100000000000000000000" + }, + "inboundConfig": { + "isEnabled": true, + "capacity": "1000000000000000000000", + "rate": "100000000000000000000" + } +} diff --git a/examples/revoke-roles.json b/examples/revoke-roles.json new file mode 100644 index 0000000..105e3b4 --- /dev/null +++ b/examples/revoke-roles.json @@ -0,0 +1,5 @@ +{ + "pool": "0x1234567890123456789012345678901234567890", + "roleType": "both", + "action": "revoke" +} diff --git a/examples/token-deployment.json b/examples/token-and-pool-deployment.json similarity index 100% rename from examples/token-deployment.json rename to examples/token-and-pool-deployment.json diff --git a/examples/token-deployment-with-remote.json b/examples/token-deployment-with-remote.json new file mode 100644 index 0000000..bd57967 --- /dev/null +++ b/examples/token-deployment-with-remote.json @@ -0,0 +1,28 @@ +{ + "name": "Cross-Chain Test Token", + "symbol": "XCTEST", + "decimals": 18, + "maxSupply": "1000000000000000000000000", + "preMint": "100000000000000000000000", + "remoteTokenPools": [ + { + "remoteChainSelector": "16015286601757825753", + "remotePoolAddress": "0x0000000000000000000000000000000000000000", + "remotePoolInitCode": "0x", + "remoteTokenAddress": "0x0000000000000000000000000000000000000000", + "remoteTokenInitCode": "0x", + "poolType": "BurnMintTokenPool", + "remoteChainConfig": { + "remotePoolFactory": "0x0000000000000000000000000000000000000000", + "remoteRouter": "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59", + "remoteRMNProxy": "0xba3f6251de62dED61Ff98590cB2fDf6871FbB991", + "remoteTokenDecimals": 18 + }, + "rateLimiterConfig": { + "isEnabled": true, + "capacity": "100000000000000000000000", + "rate": "1000000000000000000000" + } + } + ] +} diff --git a/jest.config.ts b/jest.config.ts index 98049f7..197369b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -6,7 +6,15 @@ export default { transform: { '^.+\\.ts$': 'ts-jest', }, - collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/test/**'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/test/**', '!src/typechain/**'], coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'html'], + coverageReporters: ['text', 'lcov', 'html', 'json-summary'], + coverageThreshold: { + global: { + lines: 85, + branches: 75, + functions: 85, + statements: 85, + }, + }, }; diff --git a/src/cli.ts b/src/cli.ts index a4c3116..946ebb7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,65 +1,277 @@ #!/usr/bin/env node +/** + * @fileoverview CLI entry point for TokenPool calldata generation tool. + * + * This module implements the command-line interface for generating transaction + * calldata for Chainlink CCIP TokenPool contract interactions. Built with + * Commander.js, it provides 7 commands for different TokenPool operations. + * + * Architecture: + * - **Commander.js**: CLI framework with command parsing + * - **Service Container**: Dependency injection for transaction generation + * - **Output Service**: Handles calldata and Safe JSON output + * - **Error Handling**: Automatic wrapping with user-friendly messages + * - **Validation**: Pre-execution validation of all parameters + * + * Available Commands: + * 1. `generate-chain-update` - Configure cross-chain connections + * 2. `generate-token-deployment` - Deploy token + pool via factory + * 3. `generate-pool-deployment` - Deploy pool for existing token + * 4. `generate-mint` - Mint tokens (requires minter role) + * 5. `generate-grant-roles` - Grant/revoke mint/burn roles + * 6. `generate-allow-list-updates` - Update sender allow lists + * 7. `generate-rate-limiter-config` - Configure token bucket rate limits + * + * Output Formats: + * - **Calldata** (default): Raw hex-encoded function calls for direct execution + * - **Safe JSON**: Formatted JSON for Safe Transaction Builder UI + * + * Common Usage Pattern: + * ```bash + * token-pools-calldata \ + * -i input.json \ + * -o output.txt \ + * -f calldata \ + * [command-specific-options] + * ``` + * + * @module cli + * @see {@link https://github.com/tj/commander.js} Commander.js documentation + * @see {@link https://docs.chain.link/ccip} Chainlink CCIP documentation + */ + import { Command, Option } from 'commander'; import fs from 'fs/promises'; import path from 'path'; -import prettier from 'prettier'; -import { ethers } from 'ethers'; -import { - generateChainUpdateTransaction, - createChainUpdateJSON, -} from './generators/chainUpdateCalldata'; -import logger from './utils/logger'; -import { - generateTokenAndPoolDeployment, - createTokenDeploymentJSON, -} from './generators/tokenDeployment'; +import { getServiceContainer } from './services'; +import { OUTPUT_FORMAT } from './config'; +import { withErrorHandling } from './cli/errorHandler'; +import { OutputService } from './output'; import { - generatePoolDeploymentTransaction, - createPoolDeploymentJSON, -} from './generators/poolDeployment'; -import { TokenDeploymentParams } from './types/tokenDeployment'; -import { PoolDeploymentParams } from './types/poolDeployment'; -import { SafeMetadata } from './types/safe'; -import { SafeChainUpdateMetadata } from './types/chainUpdate'; + validateChainUpdateOptions, + validateTokenDeploymentOptions, + validatePoolDeploymentOptions, + validateMintOptions, + validateAllowListOptions, + validateRateLimiterOptions, + validateGrantRolesOptions, +} from './validators'; + +// Get service container with all dependencies wired up +const container = getServiceContainer(); +const transactionService = container.transactionService; +const outputService = new OutputService(); /** - * Base options interface for all commands + * Base options interface shared across all CLI commands. + * + * @remarks + * Common Parameters: + * - **input**: Path to JSON input file (required for most commands) + * - **output**: Path to output file (optional, defaults to stdout) + * - **format**: Output format (calldata or safe-json) + * - **safe**: Safe multisig address (required for safe-json format) + * - **owner**: Safe owner address (required for safe-json format) + * - **chainId**: Chain ID (required for safe-json format) + * + * @internal */ interface BaseOptions { + /** Path to JSON input file */ input: string; + + /** Path to output file (optional, defaults to stdout) */ output?: string; - format?: 'calldata' | 'safe-json'; + + /** Output format: 'calldata' (default) or 'safe-json' */ + format?: typeof OUTPUT_FORMAT.CALLDATA | typeof OUTPUT_FORMAT.SAFE_JSON; + + /** Safe multisig contract address (required for safe-json format) */ safe?: string; + + /** Safe owner address (required for safe-json format) */ owner?: string; + + /** Chain ID where transaction will be executed (required for safe-json format) */ chainId?: string; } /** - * Options for chain update command + * Options for chain update command (`generate-chain-update`). + * + * @remarks + * Extends BaseOptions with TokenPool address parameter. + * Used to configure cross-chain connections (add/remove remote chains). + * + * @internal */ interface ChainUpdateOptions extends BaseOptions { + /** TokenPool contract address (optional, defaults to placeholder) */ tokenPool?: string; } /** - * Base options for deployment commands + * Base options for deployment commands. + * + * @remarks + * Shared by token and pool deployment commands. Includes factory address + * and CREATE2 salt for deterministic deployment. + * + * @internal */ interface BaseDeploymentOptions extends BaseOptions { - deployer: string; // TokenPoolFactory contract address + /** TokenPoolFactory contract address */ + deployer: string; + + /** 32-byte salt for CREATE2 deterministic deployment */ salt: string; + + /** Safe address (required for deployments with safe-json format) */ safe: string; } /** - * Options for token deployment command + * Options for token deployment command (`generate-token-deployment`). + * + * @remarks + * Deploys both BurnMintERC20 token and TokenPool in a single transaction + * via TokenPoolFactory.deployToken(). + * + * @internal */ interface TokenDeploymentOptions extends BaseDeploymentOptions {} /** - * Options for pool deployment command + * Options for pool deployment command (`generate-pool-deployment`). + * + * @remarks + * Deploys TokenPool for an existing token via TokenPoolFactory.deployPool(). + * + * @internal */ interface PoolDeploymentOptions extends BaseDeploymentOptions {} +/** + * Options for mint command (`generate-mint`). + * + * @remarks + * Generates mint transaction for BurnMintERC20 tokens. Requires minter role. + * + * @internal + */ +interface MintOptions extends BaseOptions { + /** Token contract address */ + token: string; + + /** Receiver address for minted tokens */ + receiver: string; + + /** Amount to mint (string for large numbers, e.g., "1000000000000000000000") */ + amount: string; +} + +/** + * Options for grant roles command (`generate-grant-roles`). + * + * @remarks + * Grants or revokes mint/burn roles to/from a TokenPool. Required before + * the pool can burn/mint tokens for cross-chain transfers. + * + * @internal + */ +interface GrantRolesOptions extends BaseOptions { + /** Token contract address */ + token: string; + + /** Pool contract address to grant/revoke roles */ + pool: string; + + /** Role type: 'mint', 'burn', or 'both' (default: 'both') */ + roleType?: 'mint' | 'burn' | 'both'; + + /** Action: 'grant' or 'revoke' (default: 'grant') */ + action?: 'grant' | 'revoke'; +} + +/** + * Options for allow list updates command (`generate-allow-list-updates`). + * + * @remarks + * Updates the sender allow list for a TokenPool. Controls which + * addresses can send/receive tokens through the pool. + * + * @internal + */ +interface AllowListUpdatesOptions extends BaseOptions { + /** Path to JSON input file with allow list updates */ + input: string; + + /** TokenPool contract address */ + pool: string; +} + +/** + * Options for rate limiter config command (`generate-rate-limiter-config`). + * + * @remarks + * Configures token bucket rate limiter for a TokenPool. Controls maximum + * transfer capacity and refill rate. + * + * @internal + */ +interface RateLimiterConfigOptions extends BaseOptions { + /** Path to JSON input file with rate limiter configuration */ + input: string; + + /** TokenPool contract address */ + pool: string; +} + +/** + * Options for accept ownership command (`generate-accept-ownership`). + * + * @remarks + * Accepts ownership of a contract using two-step ownership transfer pattern. + * Works with any Chainlink contract (tokens, pools, etc.) using this pattern. + * + * @internal + */ +interface AcceptOwnershipOptions { + /** Contract address to accept ownership of */ + address: string; + + /** Path to output file (optional, defaults to stdout) */ + output?: string; + + /** Output format: 'calldata' (default) or 'safe-json' */ + format?: typeof OUTPUT_FORMAT.CALLDATA | typeof OUTPUT_FORMAT.SAFE_JSON; + + /** Safe multisig contract address (required for safe-json format) */ + safe?: string; + + /** Safe owner address (required for safe-json format) */ + owner?: string; + + /** Chain ID where transaction will be executed (required for safe-json format) */ + chainId?: string; +} + +/** + * Creates and configures the Commander.js program instance. + * + * Factory function that initializes the root Command with program metadata. + * Commands are added to this instance later in the module. + * + * @returns Configured Commander.js Command instance + * + * @remarks + * Program Configuration: + * - **name**: 'token-pools-calldata' + * - **description**: Brief summary of tool purpose + * - **version**: Current version from package.json + * + * @internal + */ function createProgram(): Command { return new Command() .name('token-pools-calldata') @@ -67,233 +279,346 @@ function createProgram(): Command { .version('1.0.0'); } -// Function to format JSON consistently using project's prettier config -async function formatJSON(obj: unknown): Promise { - const config = await prettier.resolveConfig(process.cwd()); - return prettier.format(JSON.stringify(obj), { - ...config, - parser: 'json', - }); +/** + * Handles the `generate-chain-update` command. + * + * Generates calldata for configuring cross-chain connections on a TokenPool. + * Reads chain update configuration from JSON input file and generates transaction. + * + * @param options - Command options from Commander.js + * + * @remarks + * Workflow: + * 1. Validate options (chain ID, safe address if safe-json format) + * 2. Read input JSON file + * 3. Build metadata for Safe JSON (if applicable) + * 4. Generate transaction via TransactionService + * 5. Write output using OutputService + * + * Input JSON Format: + * ```json + * { + * "chainsToAdd": [{ "remoteChainSelector": "...", ... }], + * "chainsToRemove": ["..."] + * } + * ``` + * + * @internal + */ +async function handleChainUpdate(options: ChainUpdateOptions): Promise { + validateChainUpdateOptions(options); + + const inputPath = path.resolve(options.input); + const inputJson = await fs.readFile(inputPath, 'utf-8'); + + // Use TransactionService to generate transaction and Safe JSON + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe!, + ownerAddress: options.owner!, + tokenPoolAddress: options.tokenPool || '0xYOUR_POOL_ADDRESS', + } + : undefined; + + const { transaction, safeJson } = await transactionService.generateChainUpdate( + inputJson, + options.tokenPool || '0xYOUR_POOL_ADDRESS', + metadata, + ); + + // Write output using OutputService + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transaction, + safeJson, + options.output, + ); } -async function handleChainUpdate(options: ChainUpdateOptions): Promise { - try { - // Validate Ethereum addresses if provided - if (options.safe && !ethers.isAddress(options.safe)) { - throw new Error(`Invalid Safe address: ${String(options.safe)}`); - } - if (options.owner && !ethers.isAddress(options.owner)) { - throw new Error(`Invalid owner address: ${String(options.owner)}`); - } - if (options.tokenPool && !ethers.isAddress(options.tokenPool)) { - throw new Error(`Invalid Token Pool address: ${String(options.tokenPool)}`); - } +/** + * Handles the `generate-token-deployment` command. + * + * Generates deployment transaction for BurnMintERC20 token and TokenPool via + * TokenPoolFactory.deployToken(). Uses CREATE2 for deterministic addresses. + * + * @param options - Command options from Commander.js + * @internal + */ +async function handleTokenDeployment(options: TokenDeploymentOptions): Promise { + validateTokenDeploymentOptions(options); + + const inputPath = path.resolve(options.input); + const inputJson = await fs.readFile(inputPath, 'utf-8'); + + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe, + ownerAddress: options.owner!, + } + : undefined; + + const { transaction, safeJson } = await transactionService.generateTokenDeployment( + inputJson, + options.deployer, + options.salt, + options.safe, + metadata, + ); + + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transaction, + safeJson, + options.output, + ); +} - const inputPath = path.resolve(options.input); - const inputJson = await fs.readFile(inputPath, 'utf-8'); - const transaction = await generateChainUpdateTransaction(inputJson); - - if (options.format === 'safe-json') { - if (!options.chainId || !options.safe || !options.owner) { - throw new Error( - 'chainId, safe, and owner are required for Safe Transaction Builder JSON format', - ); - } - - const metadata: SafeChainUpdateMetadata = { - chainId: options.chainId, - safeAddress: options.safe, - ownerAddress: options.owner, - tokenPoolAddress: options.tokenPool || '0xYOUR_POOL_ADDRESS', - }; - - const safeJson = createChainUpdateJSON(transaction, metadata); - const formattedJson = await formatJSON(safeJson); - - if (options.output) { - const outputPath = path.resolve(options.output); - await fs.writeFile(outputPath, formattedJson); - logger.info('Successfully wrote Safe Transaction Builder JSON to file', { outputPath }); - } else { - console.log(formattedJson); - } - } else { - // Default format: just output the transaction data - if (options.output) { - const outputPath = path.resolve(options.output); - await fs.writeFile(outputPath, transaction.data + '\n'); - logger.info('Successfully wrote transaction data to file', { outputPath }); - } else { - console.log(transaction.data); - } - } - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to generate chain update transaction', { - error: error.message, - stack: error.stack, - }); - } else { - logger.error('Failed to generate chain update transaction', { - error: 'Unknown error', - }); - } - process.exit(1); - } +/** + * Handles the `generate-pool-deployment` command. + * + * Generates deployment transaction for TokenPool (without token) via + * TokenPoolFactory.deployPool(). For existing tokens. + * + * @param options - Command options from Commander.js + * @internal + */ +async function handlePoolDeployment(options: PoolDeploymentOptions): Promise { + validatePoolDeploymentOptions(options); + + const inputPath = path.resolve(options.input); + const inputJson = await fs.readFile(inputPath, 'utf-8'); + + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe, + ownerAddress: options.owner!, + } + : undefined; + + const { transaction, safeJson } = await transactionService.generatePoolDeployment( + inputJson, + options.deployer, + options.salt, + metadata, + ); + + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transaction, + safeJson, + options.output, + ); } -async function handleTokenDeployment(options: TokenDeploymentOptions): Promise { - try { - // Validate Ethereum addresses if provided - if (options.safe && !ethers.isAddress(options.safe)) { - throw new Error(`Invalid Safe address: ${String(options.safe)}`); - } - if (options.owner && !ethers.isAddress(options.owner)) { - throw new Error(`Invalid owner address: ${String(options.owner)}`); - } - if (!ethers.isAddress(options.deployer)) { - throw new Error(`Invalid deployer address: ${String(options.deployer)}`); - } - if (!options.salt) { - throw new Error('Salt is required'); - } - if (ethers.dataLength(options.salt) !== 32) { - throw new Error('Salt must be a 32-byte hex string'); - } +/** + * Handles the `generate-mint` command. + * + * Generates mint transaction for BurnMintERC20 tokens. Requires minter role + * to be granted to the transaction sender. + * + * @param options - Command options from Commander.js + * @internal + */ +async function handleMint(options: MintOptions): Promise { + validateMintOptions(options); - const inputPath = path.resolve(options.input); - const inputJson = await fs.readFile(inputPath, 'utf-8'); - const transaction = await generateTokenAndPoolDeployment( - inputJson, - options.deployer, - options.salt, - options.safe, - ); - - // Parse input JSON for Safe JSON format - const parsedInput = JSON.parse(inputJson) as TokenDeploymentParams; - - if (options.format === 'safe-json') { - if (!options.chainId || !options.safe || !options.owner) { - throw new Error( - 'chainId, safe, and owner are required for Safe Transaction Builder JSON format', - ); - } - - const metadata: SafeMetadata = { - chainId: options.chainId, - safeAddress: options.safe, - ownerAddress: options.owner, - }; - - const safeJson = createTokenDeploymentJSON(transaction, parsedInput, metadata); - const formattedJson = await formatJSON(safeJson); - - if (options.output) { - const outputPath = path.resolve(options.output); - await fs.writeFile(outputPath, formattedJson); - logger.info('Successfully wrote Safe Transaction Builder JSON to file', { outputPath }); - } else { - console.log(formattedJson); - } - } else { - // Default format: just output the transaction data - if (options.output) { - const outputPath = path.resolve(options.output); - await fs.writeFile(outputPath, transaction.data + '\n'); - logger.info('Successfully wrote transaction data to file', { outputPath }); - } else { - console.log(transaction.data); - } - } - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to generate token deployment', { - error: error.message, - stack: error.stack, - }); - } else { - logger.error('Failed to generate token deployment', { error: 'Unknown error' }); - } - process.exit(1); - } + // Create input JSON from command line options + const inputJson = JSON.stringify({ + receiver: options.receiver, + amount: options.amount, + }); + + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe!, + ownerAddress: options.owner!, + tokenAddress: options.token, + } + : undefined; + + const { transaction, safeJson } = await transactionService.generateMint( + inputJson, + options.token, + metadata, + ); + + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transaction, + safeJson, + options.output, + ); } -async function handlePoolDeployment(options: PoolDeploymentOptions): Promise { - try { - // Validate Ethereum addresses if provided - if (options.safe && !ethers.isAddress(options.safe)) { - throw new Error(`Invalid Safe address: ${String(options.safe)}`); - } - if (options.owner && !ethers.isAddress(options.owner)) { - throw new Error(`Invalid owner address: ${String(options.owner)}`); - } - if (!ethers.isAddress(options.deployer)) { - throw new Error(`Invalid deployer address: ${String(options.deployer)}`); - } +/** + * Handles the `generate-allow-list-updates` command. + * + * Generates transaction to update TokenPool allow list. Controls which addresses + * can send/receive tokens through the pool. + * + * @param options - Command options from Commander.js + * @internal + */ +async function handleAllowListUpdates(options: AllowListUpdatesOptions): Promise { + validateAllowListOptions(options); + + const inputPath = path.resolve(options.input); + const inputJson = await fs.readFile(inputPath, 'utf-8'); + + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe!, + ownerAddress: options.owner!, + tokenPoolAddress: options.pool, + } + : undefined; + + const { transaction, safeJson } = await transactionService.generateAllowListUpdates( + inputJson, + options.pool, + metadata, + ); + + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transaction, + safeJson, + options.output, + ); +} - if (!options.salt) { - throw new Error('Salt is required'); - } - if (ethers.dataLength(options.salt) !== 32) { - throw new Error('Salt must be a 32-byte hex string'); - } +/** + * Handles the `generate-rate-limiter-config` command. + * + * Generates transaction to configure TokenPool rate limiter. Implements token bucket + * algorithm to control transfer rate (capacity + refill rate per chain). + * + * @param options - Command options from Commander.js + * @internal + */ +async function handleRateLimiterConfig(options: RateLimiterConfigOptions): Promise { + validateRateLimiterOptions(options); + + const inputPath = path.resolve(options.input); + const inputJson = await fs.readFile(inputPath, 'utf-8'); + + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe!, + ownerAddress: options.owner!, + tokenPoolAddress: options.pool, + } + : undefined; + + const { transaction, safeJson } = await transactionService.generateRateLimiterConfig( + inputJson, + options.pool, + metadata, + ); + + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transaction, + safeJson, + options.output, + ); +} - const inputPath = path.resolve(options.input); - const inputJson = await fs.readFile(inputPath, 'utf-8'); - const transaction = await generatePoolDeploymentTransaction( - inputJson, - options.deployer, - options.salt, - ); - - // Parse input JSON for Safe JSON format - const parsedInput = JSON.parse(inputJson) as PoolDeploymentParams; - - if (options.format === 'safe-json') { - if (!options.chainId || !options.safe || !options.owner) { - throw new Error( - 'chainId, safe, and owner are required for Safe Transaction Builder JSON format', - ); - } - - const metadata: SafeMetadata = { - chainId: options.chainId, - safeAddress: options.safe, - ownerAddress: options.owner, - }; - - const safeJson = createPoolDeploymentJSON(transaction, parsedInput, metadata); - const formattedJson = await formatJSON(safeJson); - - if (options.output) { - const outputPath = path.resolve(options.output); - await fs.writeFile(outputPath, formattedJson); - logger.info('Successfully wrote Safe Transaction Builder JSON to file', { outputPath }); - } else { - console.log(formattedJson); - } - } else { - // Default format: just output the transaction data - if (options.output) { - const outputPath = path.resolve(options.output); - await fs.writeFile(outputPath, transaction.data + '\n'); - logger.info('Successfully wrote transaction data to file', { outputPath }); - } else { - console.log(transaction.data); - } - } - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to generate pool deployment', { - error: error.message, - stack: error.stack, - }); - } else { - logger.error('Failed to generate pool deployment', { error: 'Unknown error' }); +/** + * Handles the `generate-grant-roles` command. + * + * Generates transaction(s) to grant or revoke mint/burn roles. Required before + * a TokenPool can burn/mint tokens for cross-chain transfers. + * + * @param options - Command options from Commander.js + * @internal + */ +async function handleGrantRoles(options: GrantRolesOptions): Promise { + validateGrantRolesOptions(options); + + // Create input JSON from command line options + const inputJson = JSON.stringify({ + pool: options.pool, + roleType: options.roleType || 'both', + action: options.action || 'grant', + }); + + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe!, + ownerAddress: options.owner!, + tokenAddress: options.token, + } + : undefined; + + const { transactions, safeJson } = await transactionService.generateRoleManagement( + inputJson, + options.token, + metadata, + ); + + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transactions, + safeJson, + options.output, + ); +} + +/** + * Handles the `generate-accept-ownership` command. + * + * Generates transaction to accept ownership of a contract using the two-step + * ownership transfer pattern. Works with any Chainlink contract (tokens, pools, etc.). + * + * @param options - Command options from Commander.js + * @internal + */ +async function handleAcceptOwnership(options: AcceptOwnershipOptions): Promise { + // Validate safe-json format requirements + if (options.format === OUTPUT_FORMAT.SAFE_JSON) { + if (!options.chainId || !options.safe || !options.owner) { + throw new Error( + 'Chain ID (-c), safe address (-s), and owner address (-w) are required for safe-json format', + ); } - process.exit(1); } + + const metadata = + options.format === OUTPUT_FORMAT.SAFE_JSON + ? { + chainId: options.chainId!, + safeAddress: options.safe!, + ownerAddress: options.owner!, + contractAddress: options.address, + } + : undefined; + + const { transaction, safeJson } = await transactionService.generateAcceptOwnership( + options.address, + metadata, + ); + + await outputService.write( + options.format || OUTPUT_FORMAT.CALLDATA, + transaction, + safeJson, + options.output, + ); } // Initialize the program @@ -307,8 +632,8 @@ program .option('-o, --output ', 'Path to output file (defaults to stdout)') .addOption( new Option('-f, --format ', 'Output format') - .choices(['calldata', 'safe-json']) - .default('calldata'), + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), ) .option('-s, --safe
', 'Safe address (for safe-json format)') .option('-w, --owner
', 'Owner address (for safe-json format)') @@ -317,24 +642,29 @@ program '-p, --token-pool
', 'Token Pool contract address (optional, defaults to placeholder)', ) - .action(handleChainUpdate); + .action(withErrorHandling(handleChainUpdate, 'generate-chain-update')); program .command('generate-token-deployment') - .description('Generate deployment transaction for BurnMintERC20 token') + .description('Generate deployment transaction for BurnMintERC20 token and pool') .requiredOption('-i, --input ', 'Path to input JSON file') .requiredOption('-d, --deployer
', 'TokenPoolFactory contract address') .requiredOption('--salt ', 'Salt for create2') .option('-o, --output ', 'Path to output file (defaults to stdout)') .addOption( new Option('-f, --format ', 'Output format') - .choices(['calldata', 'safe-json']) - .default('calldata'), + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), ) .option('-s, --safe
', 'Safe address (required for safe-json format)') .option('-w, --owner
', 'Owner address (required for safe-json format)') .option('-c, --chain-id ', 'Chain ID (required for safe-json format)') - .action(handleTokenDeployment as (options: TokenDeploymentOptions) => Promise); + .action( + withErrorHandling( + handleTokenDeployment as (options: TokenDeploymentOptions) => Promise, + 'generate-token-deployment', + ), + ); program .command('generate-pool-deployment') @@ -345,13 +675,128 @@ program .option('-o, --output ', 'Path to output file (defaults to stdout)') .addOption( new Option('-f, --format ', 'Output format') - .choices(['calldata', 'safe-json']) - .default('calldata'), + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), + ) + .option('-s, --safe
', 'Safe address (required for safe-json format)') + .option('-w, --owner
', 'Owner address (required for safe-json format)') + .option('-c, --chain-id ', 'Chain ID (required for safe-json format)') + .action( + withErrorHandling( + handlePoolDeployment as (options: PoolDeploymentOptions) => Promise, + 'generate-pool-deployment', + ), + ); + +program + .command('generate-mint') + .description('Generate mint transaction for BurnMintERC20 token') + .requiredOption('-t, --token
', 'Token contract address') + .requiredOption('-r, --receiver
', 'Receiver address') + .requiredOption('-a, --amount ', 'Amount to mint (as string for large numbers)') + .option('-o, --output ', 'Path to output file (defaults to stdout)') + .addOption( + new Option('-f, --format ', 'Output format') + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), + ) + .option('-s, --safe
', 'Safe address (required for safe-json format)') + .option('-w, --owner
', 'Owner address (required for safe-json format)') + .option('-c, --chain-id ', 'Chain ID (required for safe-json format)') + .action( + withErrorHandling(handleMint as (options: MintOptions) => Promise, 'generate-mint'), + ); + +program + .command('generate-grant-roles') + .description('Generate transaction to grant or revoke mint/burn roles to/from token pool') + .requiredOption('-t, --token
', 'Token contract address') + .requiredOption('-p, --pool
', 'Pool contract address') + .addOption( + new Option('--role-type ', 'Role type').choices(['mint', 'burn', 'both']).default('both'), + ) + .addOption( + new Option('--action ', 'Action to perform') + .choices(['grant', 'revoke']) + .default('grant'), + ) + .option('-o, --output ', 'Path to output file (defaults to stdout)') + .addOption( + new Option('-f, --format ', 'Output format') + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), + ) + .option('-s, --safe
', 'Safe address (required for safe-json format)') + .option('-w, --owner
', 'Owner address (required for safe-json format)') + .option('-c, --chain-id ', 'Chain ID (required for safe-json format)') + .action( + withErrorHandling( + handleGrantRoles as (options: GrantRolesOptions) => Promise, + 'generate-grant-roles', + ), + ); + +program + .command('generate-allow-list-updates') + .description('Generate transaction to update token pool allow list') + .requiredOption('-i, --input ', 'Path to input JSON file') + .requiredOption('-p, --pool
', 'Token pool contract address') + .option('-o, --output ', 'Path to output file (defaults to stdout)') + .addOption( + new Option('-f, --format ', 'Output format') + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), + ) + .option('-s, --safe
', 'Safe address (required for safe-json format)') + .option('-w, --owner
', 'Owner address (required for safe-json format)') + .option('-c, --chain-id ', 'Chain ID (required for safe-json format)') + .action( + withErrorHandling( + handleAllowListUpdates as (options: AllowListUpdatesOptions) => Promise, + 'generate-allow-list-updates', + ), + ); + +program + .command('generate-rate-limiter-config') + .description('Generate transaction to update chain rate limiter configuration') + .requiredOption('-i, --input ', 'Path to input JSON file') + .requiredOption('-p, --pool
', 'Token pool contract address') + .option('-o, --output ', 'Path to output file (defaults to stdout)') + .addOption( + new Option('-f, --format ', 'Output format') + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), + ) + .option('-s, --safe
', 'Safe address (required for safe-json format)') + .option('-w, --owner
', 'Owner address (required for safe-json format)') + .option('-c, --chain-id ', 'Chain ID (required for safe-json format)') + .action( + withErrorHandling( + handleRateLimiterConfig as (options: RateLimiterConfigOptions) => Promise, + 'generate-rate-limiter-config', + ), + ); + +program + .command('generate-accept-ownership') + .description('Generate transaction to accept ownership of a contract (token, pool, etc.)') + .requiredOption('-a, --address
', 'Contract address to accept ownership of') + .option('-o, --output ', 'Path to output file (defaults to stdout)') + .addOption( + new Option('-f, --format ', 'Output format') + .choices([OUTPUT_FORMAT.CALLDATA, OUTPUT_FORMAT.SAFE_JSON]) + .default(OUTPUT_FORMAT.CALLDATA), ) .option('-s, --safe
', 'Safe address (required for safe-json format)') .option('-w, --owner
', 'Owner address (required for safe-json format)') .option('-c, --chain-id ', 'Chain ID (required for safe-json format)') - .action(handlePoolDeployment as (options: PoolDeploymentOptions) => Promise); + .action( + withErrorHandling( + handleAcceptOwnership as (options: AcceptOwnershipOptions) => Promise, + 'generate-accept-ownership', + ), + ); // Parse command line arguments void program.parse(process.argv); diff --git a/src/cli/commandHelpers.ts b/src/cli/commandHelpers.ts new file mode 100644 index 0000000..0a5a926 --- /dev/null +++ b/src/cli/commandHelpers.ts @@ -0,0 +1,109 @@ +/** + * @fileoverview Commander.js command handler utilities. + * + * This module provides type-safe wrapper functions for Commander.js command + * handlers. Eliminates the need for unsafe type assertions while maintaining + * full TypeScript type safety for CLI command options. + * + * Key Features: + * - Generic type-safe command action wrapper + * - No runtime overhead (identity function) + * - Removes need for 'as' type assertions + * - Maintains Commander.js action signature compatibility + * + * @module cli/commandHelpers + * @see {@link https://github.com/tj/commander.js} Commander.js documentation + */ + +/** + * Creates a type-safe wrapper for Commander.js command action handlers. + * + * Identity function that preserves the handler as-is while providing TypeScript + * with explicit type information. Eliminates the need for unsafe `as` type + * assertions when defining command actions. + * + * @param handler - The async command handler function + * @returns The same handler function with explicit typing + * + * @remarks + * Purpose: + * - Provides explicit typing for Commander.js action handlers + * - Avoids TypeScript errors about implicit 'any' types + * - No runtime overhead (identity function) + * - Better IDE autocomplete and type checking + * + * Commander.js Integration: + * - Works with `.action()` method of Command + * - Handler receives parsed options object + * - Options type must match command's option definitions + * + * Type Safety: + * - Generic `TOptions` type parameter inferred from handler + * - Ensures options parameter matches expected interface + * - Catches type mismatches at compile time + * + * @example + * ```typescript + * import { createCommandAction } from './commandHelpers'; + * import { Command } from 'commander'; + * + * interface DeployOptions { + * deployer: string; + * salt: string; + * format?: string; + * } + * + * async function handleDeploy(options: DeployOptions): Promise { + * console.log(`Deploying with ${options.deployer}`); + * // ... deployment logic + * } + * + * const program = new Command(); + * program + * .command('deploy') + * .requiredOption('-d, --deployer
', 'Deployer address') + * .requiredOption('--salt ', 'Salt for CREATE2') + * .option('-f, --format ', 'Output format') + * .action(createCommandAction(handleDeploy)); + * // ^^ Properly typed without 'as' assertion + * ``` + * + * @example + * ```typescript + * // Without createCommandAction (requires type assertion) + * program + * .command('deploy') + * .action(handleDeploy as (options: DeployOptions) => Promise); + * // ^^ Unsafe type assertion + * + * // With createCommandAction (type-safe) + * program + * .command('deploy') + * .action(createCommandAction(handleDeploy)); + * // ^^ No assertion needed, fully type-safe + * ``` + * + * @example + * ```typescript + * // Type mismatch caught at compile time + * interface WrongOptions { + * foo: string; + * } + * + * async function handler(options: DeployOptions): Promise { + * // ... + * } + * + * // TypeScript error: Type mismatch + * const action = createCommandAction(handler); + * // ^^ Error: Types don't match + * ``` + * + * @typeParam TOptions - Type of the options object passed to the handler + * @public + */ +export function createCommandAction( + handler: (options: TOptions) => Promise, +): (options: TOptions) => Promise { + return handler; +} diff --git a/src/cli/errorHandler.ts b/src/cli/errorHandler.ts new file mode 100644 index 0000000..789c198 --- /dev/null +++ b/src/cli/errorHandler.ts @@ -0,0 +1,319 @@ +/** + * @fileoverview CLI error handling and formatting utilities. + * + * This module provides error handling infrastructure for CLI commands, separating + * user-facing error messages from technical logging details. Ensures consistent + * error formatting across all CLI commands. + * + * Key Features: + * - CLIError class for user-friendly error messages + * - handleCLIError() for consistent error formatting + * - withErrorHandling() wrapper for command handlers + * - Automatic technical detail logging + * - Clean process exit on errors + * + * Error Handling Strategy: + * - User sees: Clean, actionable error message + * - Logs contain: Full technical details, stack traces, context + * - Process exits: With code 1 on any error + * + * @module cli/errorHandler + */ + +import { ZodError } from 'zod'; + +import logger from '../utils/logger'; + +/** + * Formats a ZodError into a human-readable string. + * + * Extracts validation errors from Zod and formats them as "path: message" pairs, + * making it easy for users to identify exactly which fields failed validation. + * + * @param error - The ZodError to format + * @returns Formatted string with all validation errors + * + * @example + * ```typescript + * // Single error + * formatZodError(zodError); + * // Returns: "remoteChainType: Required" + * + * // Nested path + * formatZodError(zodError); + * // Returns: "[1][0].remoteChainType: Required" + * + * // Multiple errors + * formatZodError(zodError); + * // Returns: "remoteChainType: Required, capacity: Expected string, received number" + * ``` + * + * @public + */ +export function formatZodError(error: ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path + .map((p) => (typeof p === 'number' ? `[${p}]` : `.${String(p)}`)) + .join('') + .replace(/^\./, ''); // Remove leading dot if present + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join(', '); +} + +/** + * Custom error class for CLI command failures. + * + * Extends standard Error with optional technical details that are logged but + * not shown to end users. Provides separation between user-facing messages + * and debugging information. + * + * @remarks + * Design Pattern: + * - **message**: User-friendly error message (shown in console) + * - **technicalDetails**: Optional debugging info (logged, not displayed) + * - **name**: Set to 'CLIError' for error identification + * + * Use Cases: + * - Validation errors with user guidance + * - Operation failures with technical context + * - Configuration errors with suggestions + * + * @example + * ```typescript + * // Simple user message + * throw new CLIError('Invalid token address format'); + * + * // With technical details for logging + * throw new CLIError( + * 'Failed to generate deployment transaction', + * { + * deployer: '0x1234...', + * salt: '0x5678...', + * originalError: error.message + * } + * ); + * ``` + * + * @example + * ```typescript + * // In command handler + * if (!ethers.isAddress(options.token)) { + * throw new CLIError( + * 'Invalid token address. Please provide a valid Ethereum address (0x + 40 hex characters)', + * { providedAddress: options.token } + * ); + * } + * ``` + * + * @public + */ +export class CLIError extends Error { + /** + * Creates a new CLIError instance. + * + * @param message - User-friendly error message (displayed to user) + * @param technicalDetails - Optional technical details (logged only) + */ + constructor( + message: string, + public readonly technicalDetails?: unknown, + ) { + super(message); + this.name = 'CLIError'; + } +} + +/** + * Handles and formats CLI command errors for end users. + * + * Central error handler that processes errors from CLI commands, displays + * user-friendly messages, logs technical details, and exits the process. + * Handles CLIError, standard Error, and unknown error types differently. + * + * @param error - The error to handle (any type) + * @param commandName - Name of the command that failed (e.g., 'generate-token-deployment') + * @returns Never returns (always exits process) + * + * @remarks + * Error Type Handling: + * + * **CLIError**: + * - Console: User-friendly message from error.message + * - Log: Error message + technical details (if provided) + * - Example: "Invalid token address format" + * + * **Standard Error**: + * - Console: Error message + * - Log: Error message + stack trace + * - Example: "Failed to read input file" + * + * **Unknown Type**: + * - Console: Generic unknown error message + * - Log: Raw error value + * - Example: thrown string or object + * + * Process Exit: + * - Always calls `process.exit(1)` after handling + * - Ensures CLI exits with error code + * - Never returns (return type is `never`) + * + * Console Output Format: + * ``` + * Error in generate-token-deployment: + * Invalid token address. Please provide a valid Ethereum address. + * ``` + * + * @example + * ```typescript + * try { + * await generateTokenDeployment(options); + * } catch (error) { + * handleCLIError(error, 'generate-token-deployment'); + * // Process exits here with code 1 + * } + * ``` + * + * @example + * ```typescript + * // Handling CLIError with technical details + * try { + * if (!isValid) { + * throw new CLIError( + * 'Deployment failed. Check your parameters.', + * { deployer, salt, reason: 'invalid salt length' } + * ); + * } + * } catch (error) { + * handleCLIError(error, 'deploy'); + * // User sees: "Deployment failed. Check your parameters." + * // Log contains: Full technical details + * } + * ``` + * + * @public + */ +export function handleCLIError(error: unknown, commandName: string): never { + if (error instanceof CLIError) { + // User-friendly error message + console.error(`\nError in ${commandName}:`); + console.error(error.message); + + // Log technical details for debugging + if (error.technicalDetails) { + logger.error(`${commandName} failed`, { + error: error.message, + technicalDetails: error.technicalDetails, + }); + } else { + logger.error(`${commandName} failed`, { error: error.message }); + } + } else if (error instanceof Error) { + // Generic error handling + console.error(`\nError in ${commandName}:`); + console.error(error.message); + + // Display validation details from cause if available + if ('cause' in error && error.cause instanceof ZodError) { + console.error(`Details: ${formatZodError(error.cause)}`); + } else if ('cause' in error && error.cause instanceof Error) { + console.error(`Caused by: ${error.cause.message}`); + } + + logger.error(`${commandName} failed`, { + error: error.message, + stack: error.stack, + }); + } else { + // Unknown error type + console.error(`\nUnknown error in ${commandName}`); + logger.error(`${commandName} failed with unknown error`, { error }); + } + + process.exit(1); +} + +/** + * Wraps a CLI command handler with automatic error handling. + * + * Higher-order function that wraps command handlers to catch and handle all + * errors automatically. Simplifies command definition by removing need for + * try/catch blocks in each handler. + * + * @param handler - The async command handler function to wrap + * @param commandName - Name of the command (for error messages) + * @returns Wrapped handler with error handling + * + * @remarks + * Usage Pattern: + * - Wrap all Commander.js `.action()` handlers + * - Handler receives options from Commander + * - Any thrown error is caught and formatted + * - Process exits on error + * + * Benefits: + * - Consistent error handling across all commands + * - No need for try/catch in individual handlers + * - Centralized error formatting and logging + * - Clean command handler code + * + * Type Safety: + * - Generic `T extends unknown[]` preserves argument types + * - Works with any async handler signature + * - Maintains full TypeScript type checking + * + * @example + * ```typescript + * import { withErrorHandling } from './errorHandler'; + * import { Command } from 'commander'; + * + * async function handleDeploy(options: DeployOptions): Promise { + * // No try/catch needed - errors handled automatically + * const inputJson = await fs.readFile(options.input, 'utf-8'); + * await generateDeployment(inputJson, options.deployer); + * } + * + * const program = new Command(); + * program + * .command('deploy') + * .requiredOption('-i, --input ', 'Input file') + * .action(withErrorHandling(handleDeploy, 'deploy')); + * // ^^ Wraps handler with error handling + * ``` + * + * @example + * ```typescript + * // Without withErrorHandling (manual try/catch) + * async function handler(options: Options): Promise { + * try { + * await doWork(options); + * } catch (error) { + * console.error('Error:', error); + * process.exit(1); + * } + * } + * + * // With withErrorHandling (automatic) + * async function handler(options: Options): Promise { + * await doWork(options); // Errors caught automatically + * } + * + * program.action(withErrorHandling(handler, 'command-name')); + * ``` + * + * @typeParam T - Tuple type of handler arguments (inferred from handler) + * @public + */ +export function withErrorHandling( + handler: (...args: T) => Promise, + commandName: string, +): (...args: T) => Promise { + return async (...args: T): Promise => { + try { + await handler(...args); + } catch (error) { + handleCLIError(error, commandName); + } + }; +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..b36a4b1 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,259 @@ +/** + * @fileoverview Default values and configuration constants. + * + * This module defines application-wide default values for transactions, Safe + * multisig operations, and output formatting. Provides type-safe constants for + * CLI commands and transaction generation. + * + * Key Features: + * - Transaction defaults (value, version) + * - Safe operation type enums (CALL vs DELEGATE_CALL) + * - Output format options (calldata vs Safe JSON) + * - Type-safe constants with `as const` assertions + * + * Usage Pattern: + * - Import DEFAULTS for transaction and output defaults + * - Import SAFE_OPERATION for operation type values + * - Import OUTPUT_FORMAT for format validation + * - Use OutputFormat type for type-safe format parameters + * + * @example + * ```typescript + * import { DEFAULTS, SAFE_OPERATION, OUTPUT_FORMAT } from './config'; + * + * const txValue = DEFAULTS.TRANSACTION_VALUE; // '0' + * const format = DEFAULTS.OUTPUT_FORMAT; // 'calldata' + * const operation = SAFE_OPERATION.CALL; // 0 + * ``` + * + * @module config/defaults + */ + +/** + * Default values for transactions and Safe multisig operations. + * + * Defines application-wide defaults for transaction value, Safe transaction + * version, and CLI output format. All values are immutable type-safe constants. + * + * @remarks + * Constants: + * - **TRANSACTION_VALUE**: '0' (no ETH sent with transactions) + * - **SAFE_TX_VERSION**: '1.0' (Safe Transaction Builder format version) + * - **OUTPUT_FORMAT**: 'calldata' (default CLI output format) + * + * Design Decisions: + * - Transaction value is '0' since all operations are contract calls (no ETH transfer) + * - Safe version '1.0' matches Safe Transaction Builder JSON schema + * - Default format 'calldata' for simplicity (Safe JSON requires additional params) + * + * Type Safety: + * - `as const` makes all values readonly and literal types + * - Object itself is readonly via outer `as const` + * - TypeScript enforces exact string literal types + * + * @example + * ```typescript + * // Using transaction defaults + * const transaction = { + * value: DEFAULTS.TRANSACTION_VALUE, // '0' + * to: contractAddress, + * data: calldata + * }; + * ``` + * + * @example + * ```typescript + * // Using Safe transaction defaults + * const safeTx = { + * version: DEFAULTS.SAFE_TX_VERSION, // '1.0' + * chainId: '1', + * // ... other Safe tx fields + * }; + * ``` + * + * @example + * ```typescript + * // Using output format default + * const format = options.format || DEFAULTS.OUTPUT_FORMAT; // 'calldata' + * ``` + * + * @public + */ +export const DEFAULTS = { + /** + * Default transaction value in wei (0 ETH). + * + * @remarks + * All generated transactions are contract calls with no ETH transfer. + * String type for consistency with ethers.js and to avoid precision issues. + */ + TRANSACTION_VALUE: '0' as const, + + /** + * Safe Transaction Builder JSON format version. + * + * @remarks + * Version '1.0' matches the Safe Transaction Builder schema. + * Used in the 'version' field of Safe JSON output. + */ + SAFE_TX_VERSION: '1.0' as const, + + /** + * Default CLI output format (raw calldata). + * + * @remarks + * - 'calldata': Hex-encoded function calldata (default) + * - 'safe-json': Safe Transaction Builder JSON format + * + * Calldata is the default for simplicity (no additional params required). + */ + OUTPUT_FORMAT: 'calldata' as const, +} as const; + +/** + * Safe multisig operation type constants. + * + * Defines operation types for Safe Transaction Builder JSON format. Maps + * operation names to their numeric values used in Safe transactions. + * + * @remarks + * Operation Types: + * - **CALL (0)**: Standard contract call (most common) + * - **DELEGATE_CALL (1)**: Delegatecall operation (advanced use cases) + * + * Safe Transaction Format: + * - These values appear in the 'operation' field of Safe transactions + * - CALL is used for all generated transactions in this application + * - DELEGATE_CALL allows Safe to execute code in its own context + * + * Type Safety: + * - Numeric literals with `as const` for type narrowing + * - TypeScript infers exact values (0 | 1) not just number + * + * @example + * ```typescript + * // Using in Safe transaction + * const safeTx = { + * to: contractAddress, + * value: '0', + * data: calldata, + * operation: SAFE_OPERATION.CALL, // 0 + * }; + * ``` + * + * @example + * ```typescript + * // Advanced: delegatecall operation + * const delegateTx = { + * to: implementationAddress, + * value: '0', + * data: calldata, + * operation: SAFE_OPERATION.DELEGATE_CALL, // 1 + * }; + * ``` + * + * @public + */ +export const SAFE_OPERATION = { + /** Standard contract call (operation type 0) */ + CALL: 0 as const, + + /** Delegatecall operation (operation type 1) */ + DELEGATE_CALL: 1 as const, +} as const; + +/** + * Output format option constants. + * + * Defines valid output format values for CLI commands. Used for format + * validation and as the source of truth for supported formats. + * + * @remarks + * Format Options: + * - **CALLDATA**: Raw hex-encoded calldata (simple, direct) + * - **SAFE_JSON**: Safe Transaction Builder JSON (requires chainId, safe, owner) + * + * Usage Context: + * - CLI flag validation: `-f, --format ` + * - Output generation routing + * - Type narrowing via OutputFormat type + * + * Type Safety: + * - String literals with `as const` for exact type inference + * - Used in OutputFormat discriminated union type + * + * @example + * ```typescript + * // Format validation + * const validFormats = Object.values(OUTPUT_FORMAT); // ['calldata', 'safe-json'] + * + * if (!validFormats.includes(format)) { + * throw new Error('Invalid format'); + * } + * ``` + * + * @example + * ```typescript + * // Type-safe format checking + * if (format === OUTPUT_FORMAT.SAFE_JSON) { + * // Generate Safe JSON output + * return generateSafeJSON(transactions, options); + * } else if (format === OUTPUT_FORMAT.CALLDATA) { + * // Generate raw calldata + * return generateCalldata(transactions); + * } + * ``` + * + * @public + */ +export const OUTPUT_FORMAT = { + /** Raw hex-encoded calldata format */ + CALLDATA: 'calldata' as const, + + /** Safe Transaction Builder JSON format */ + SAFE_JSON: 'safe-json' as const, +} as const; + +/** + * Type representing valid output format values. + * + * Union type derived from OUTPUT_FORMAT constant values. Ensures type-safe + * format parameters throughout the application. + * + * @remarks + * Type Definition: + * - Extracts all values from OUTPUT_FORMAT object + * - Results in: 'calldata' | 'safe-json' + * - Automatically stays in sync with OUTPUT_FORMAT changes + * + * Usage: + * - Function parameters requiring format specification + * - Type guards for format discrimination + * - Interface definitions for options objects + * + * @example + * ```typescript + * // Function parameter typing + * function generateOutput( + * transactions: Transaction[], + * format: OutputFormat + * ): string | SafeJSON { + * if (format === 'safe-json') { + * return generateSafeJSON(transactions); + * } + * return generateCalldata(transactions); + * } + * ``` + * + * @example + * ```typescript + * // Interface usage + * interface CommandOptions { + * format?: OutputFormat; // 'calldata' | 'safe-json' + * output?: string; + * } + * ``` + * + * @public + */ +export type OutputFormat = (typeof OUTPUT_FORMAT)[keyof typeof OUTPUT_FORMAT]; diff --git a/src/config/environment.ts b/src/config/environment.ts new file mode 100644 index 0000000..6fa80f4 --- /dev/null +++ b/src/config/environment.ts @@ -0,0 +1,179 @@ +/** + * @fileoverview Environment detection and configuration. + * + * This module provides runtime environment detection based on NODE_ENV, + * with convenient boolean flags for production, development, and test modes. + * Implements singleton pattern for consistent environment state across the + * application. + * + * Key Features: + * - NODE_ENV detection with 'development' default + * - Boolean flags for environment checks (isProduction, isDevelopment, isTest) + * - Singleton instance for global access + * - Type-safe environment configuration + * + * Usage Pattern: + * - Import `environment` singleton for most use cases + * - Use `getEnvironmentConfig()` only when fresh detection needed + * + * @example + * ```typescript + * import { environment } from './config'; + * + * if (environment.isDevelopment) { + * console.log('Running in development mode'); + * } + * + * if (environment.isProduction) { + * // Production-only configuration + * } + * ``` + * + * @module config/environment + */ + +/** + * Environment configuration interface. + * + * Provides type-safe access to environment settings with convenient boolean + * flags for common environment checks. + * + * @remarks + * Environment Values: + * - **production**: Set NODE_ENV=production for production builds + * - **development**: Default if NODE_ENV not set + * - **test**: Set NODE_ENV=test for test runs + * + * Boolean Flags: + * - Only one flag will be true at a time + * - Flags derived from nodeEnv via strict equality check + * - Custom environments will have all flags false + * + * @example + * ```typescript + * const config: EnvironmentConfig = { + * nodeEnv: 'production', + * isProduction: true, + * isDevelopment: false, + * isTest: false + * }; + * ``` + * + * @public + */ +export interface EnvironmentConfig { + /** Current NODE_ENV value (defaults to 'development') */ + nodeEnv: string; + + /** True when NODE_ENV === 'production' */ + isProduction: boolean; + + /** True when NODE_ENV === 'development' (default) */ + isDevelopment: boolean; + + /** True when NODE_ENV === 'test' */ + isTest: boolean; +} + +/** + * Creates environment configuration from current NODE_ENV. + * + * Factory function that reads process.env.NODE_ENV and constructs an + * EnvironmentConfig with derived boolean flags. Defaults to 'development' + * if NODE_ENV is not set. + * + * @returns Environment configuration object with flags + * + * @remarks + * Default Behavior: + * - Reads process.env.NODE_ENV + * - Falls back to 'development' if undefined + * - Creates boolean flags via strict equality + * + * Environment Detection: + * - Production: NODE_ENV=production + * - Development: NODE_ENV=development (or unset) + * - Test: NODE_ENV=test + * + * Singleton Pattern: + * - Exported `environment` const calls this once at import + * - Use `environment` singleton for most cases + * - Call this function directly only if fresh detection needed + * + * @example + * ```typescript + * // Manual environment detection + * const config = getEnvironmentConfig(); + * console.log(config.nodeEnv); // 'development' + * console.log(config.isDevelopment); // true + * ``` + * + * @example + * ```typescript + * // With NODE_ENV set + * process.env.NODE_ENV = 'production'; + * const config = getEnvironmentConfig(); + * console.log(config.isProduction); // true + * console.log(config.isDevelopment); // false + * ``` + * + * @public + */ +export function getEnvironmentConfig(): EnvironmentConfig { + const nodeEnv = process.env.NODE_ENV || 'development'; + + return { + nodeEnv, + isProduction: nodeEnv === 'production', + isDevelopment: nodeEnv === 'development', + isTest: nodeEnv === 'test', + }; +} + +/** + * Singleton environment configuration instance. + * + * Pre-initialized environment config available for import across the + * application. Provides immediate access to environment flags without + * needing to call getEnvironmentConfig(). + * + * @remarks + * Design Pattern: + * - Singleton pattern for consistent state + * - Initialized once at module import + * - Environment detected at application startup + * - Prefer this over calling getEnvironmentConfig() + * + * Use Cases: + * - Conditional logic based on environment + * - Environment-specific logging + * - Feature flags for dev/prod + * - Test-specific behavior + * + * @example + * ```typescript + * import { environment } from './config'; + * + * // Environment-based conditional logic + * if (environment.isProduction) { + * // Production configuration + * enableProductionLogging(); + * } else if (environment.isDevelopment) { + * // Development configuration + * enableDebugMode(); + * } + * ``` + * + * @example + * ```typescript + * // Environment-based feature flags + * const shouldLogDetails = environment.isDevelopment || environment.isTest; + * + * if (shouldLogDetails) { + * logger.debug('Detailed debug information', { context }); + * } + * ``` + * + * @public + */ +export const environment = getEnvironmentConfig(); diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..b7b0e4d --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview Configuration module barrel export. + * + * This module provides centralized configuration management for the application. + * Re-exports all configuration modules for convenient single-import access. + * + * Configuration Categories: + * - **Environment**: NODE_ENV detection and environment flags + * - **Validation**: Rules and constraints for input validation + * - **Defaults**: Default values for transactions and operations + * - **Logging**: Logging configuration and settings + * - **Bytecodes**: Contract bytecodes (re-exported from constants) + * + * @example + * ```typescript + * import { + * environment, + * VALIDATION_RULES, + * DEFAULTS, + * LOGGING_CONFIG, + * OUTPUT_FORMAT + * } from './config'; + * + * console.log(environment.isDevelopment); + * console.log(VALIDATION_RULES.SALT_LENGTH); + * console.log(DEFAULTS.TRANSACTION_VALUE); + * ``` + * + * @module config + */ + +// Environment configuration +export * from './environment'; + +// Validation configuration +export * from './validation'; + +// Default values and constants +export * from './defaults'; + +// Logging configuration +export * from './logging'; + +// Re-export bytecodes from constants (keep these separate due to size) +export { BYTECODES } from '../constants/bytecodes'; diff --git a/src/config/logging.ts b/src/config/logging.ts new file mode 100644 index 0000000..cebbd4a --- /dev/null +++ b/src/config/logging.ts @@ -0,0 +1,153 @@ +/** + * @fileoverview Logging configuration and settings. + * + * This module defines logging configuration for the Winston logger, including + * log levels, file paths, service identification, and environment-based console + * output control. Provides type-safe, centralized logging configuration. + * + * Key Features: + * - Default log level configuration + * - Service name for structured logging + * - File paths for error and combined logs + * - Environment-based console logging (disabled in production) + * - Type-safe constants with `as const` assertions + * + * Usage Pattern: + * - Import LOGGING_CONFIG in logger setup + * - All values are compile-time constants + * - Console logging auto-disabled in production + * + * @example + * ```typescript + * import { LOGGING_CONFIG } from './config'; + * + * const logger = winston.createLogger({ + * level: LOGGING_CONFIG.level, + * defaultMeta: { service: LOGGING_CONFIG.serviceName }, + * transports: [ + * new winston.transports.File({ filename: LOGGING_CONFIG.files.error }) + * ] + * }); + * ``` + * + * @module config/logging + */ + +import { environment } from './environment'; + +/** + * Logging configuration with environment-based settings. + * + * Defines Winston logger configuration including log level, service name, + * file paths, and console output control. Console logging is automatically + * disabled in production environments. + * + * @remarks + * Configuration Values: + * - **level**: 'info' (logs info, warn, error levels) + * - **serviceName**: 'token-pool-calldata' (for structured logging context) + * - **files.error**: 'error.log' (error-level logs only) + * - **files.combined**: 'combined.log' (all log levels) + * - **enableConsole**: false in production, true otherwise + * + * Log Levels (Winston): + * - error: Error messages and exceptions + * - warn: Warning messages + * - info: Informational messages (default level) + * - debug: Debug information (not logged by default) + * + * Environment Behavior: + * - **Production**: File logging only (no console output) + * - **Development/Test**: Both file and console logging + * - Console auto-disabled via `!environment.isProduction` + * + * Type Safety: + * - All values use `as const` for literal type inference + * - Object is readonly via outer `as const` + * - TypeScript enforces exact values + * + * @example + * ```typescript + * // Winston logger configuration + * const logger = winston.createLogger({ + * level: LOGGING_CONFIG.level, + * defaultMeta: { service: LOGGING_CONFIG.serviceName }, + * transports: [ + * new winston.transports.File({ + * filename: LOGGING_CONFIG.files.error, + * level: 'error' + * }), + * new winston.transports.File({ + * filename: LOGGING_CONFIG.files.combined + * }) + * ] + * }); + * ``` + * + * @example + * ```typescript + * // Conditional console transport + * if (LOGGING_CONFIG.enableConsole) { + * logger.add(new winston.transports.Console({ + * format: winston.format.simple() + * })); + * } + * ``` + * + * @example + * ```typescript + * // Service context in logs + * logger.info('Operation completed', { + * // service: 'token-pool-calldata' added automatically + * operation: 'token-deployment', + * duration: 1234 + * }); + * ``` + * + * @public + */ +export const LOGGING_CONFIG = { + /** + * Default Winston log level. + * + * @remarks + * Level 'info' captures: error, warn, info (excludes debug). + * Winston levels: error(0) > warn(1) > info(2) > debug(3). + */ + level: 'info' as const, + + /** + * Service identifier for structured logging. + * + * @remarks + * Used in Winston's defaultMeta for log correlation. + * Appears in all log entries for service identification. + */ + serviceName: 'token-pool-calldata' as const, + + /** + * Log file path configuration. + * + * @remarks + * - **error**: Error-level logs only (level: 'error') + * - **combined**: All log levels (level: LOGGING_CONFIG.level) + */ + files: { + /** Error log file path (error level only) */ + error: 'error.log' as const, + + /** Combined log file path (all levels) */ + combined: 'combined.log' as const, + }, + + /** + * Console logging flag (environment-based). + * + * @remarks + * - **true**: Development and test environments (console output enabled) + * - **false**: Production environment (console output disabled) + * + * Automatically set via `!environment.isProduction`. + */ + enableConsole: !environment.isProduction, +} as const; diff --git a/src/config/validation.ts b/src/config/validation.ts new file mode 100644 index 0000000..9ec967a --- /dev/null +++ b/src/config/validation.ts @@ -0,0 +1,201 @@ +/** + * @fileoverview Validation rules and error message constants. + * + * This module defines validation constraints for blockchain-related inputs + * (addresses, salts) and standardized error messages. Used throughout the + * application for consistent validation and error reporting. + * + * Key Features: + * - Type-safe constants with `as const` assertions + * - Ethereum address and salt length validation rules + * - Centralized error messages for validation failures + * - Immutable configuration objects + * + * Usage Pattern: + * - Import VALIDATION_RULES for length checks + * - Import VALIDATION_ERRORS for consistent error messages + * - All values are compile-time constants + * + * @example + * ```typescript + * import { VALIDATION_RULES, VALIDATION_ERRORS } from './config'; + * + * if (salt.length !== VALIDATION_RULES.SALT_LENGTH) { + * throw new Error(VALIDATION_ERRORS.SALT_REQUIRED); + * } + * + * if (address.length !== VALIDATION_RULES.ADDRESS_LENGTH) { + * throw new Error(VALIDATION_ERRORS.INVALID_TOKEN_ADDRESS); + * } + * ``` + * + * @module config/validation + */ + +/** + * Validation rules for blockchain-related inputs. + * + * Defines length constraints and formatting rules for Ethereum addresses, + * CREATE2 salts, and other blockchain primitives. All values are type-safe + * constants using `as const` assertions. + * + * @remarks + * Constants: + * - **SALT_LENGTH**: 66 characters (0x + 64 hex digits = 32 bytes) + * - **ADDRESS_LENGTH**: 42 characters (0x + 40 hex digits = 20 bytes) + * - **SALT_BYTE_LENGTH**: 32 bytes (raw byte count without 0x prefix) + * + * Ethereum Standards: + * - Addresses: 20 bytes (40 hex chars + 0x prefix) + * - Salts: 32 bytes for CREATE2 (64 hex chars + 0x prefix) + * - All hex strings include '0x' prefix in length + * + * Type Safety: + * - `as const` makes all values readonly and literal types + * - Object itself is readonly via `as const` on container + * - TypeScript enforces exact values (e.g., 66, not number) + * + * @example + * ```typescript + * // Salt validation + * const salt = '0x1234...'; // 66 chars total + * if (salt.length !== VALIDATION_RULES.SALT_LENGTH) { + * throw new Error('Invalid salt length'); + * } + * ``` + * + * @example + * ```typescript + * // Address validation + * const address = '0xabcd...'; // 42 chars total + * if (address.length !== VALIDATION_RULES.ADDRESS_LENGTH) { + * throw new Error('Invalid address length'); + * } + * ``` + * + * @public + */ +export const VALIDATION_RULES = { + /** + * Salt string length including 0x prefix (66 characters). + * + * @remarks + * Format: 0x + 64 hexadecimal characters = 32 bytes + * Used for CREATE2 deterministic deployments + * + * @example '0x0000000000000000000000000000000000000000000000000000000123456789' + */ + SALT_LENGTH: 66 as const, + + /** + * Ethereum address string length including 0x prefix (42 characters). + * + * @remarks + * Format: 0x + 40 hexadecimal characters = 20 bytes + * Standard Ethereum address format + * + * @example '0x779877A7B0D9E8603169DdbD7836e478b4624789' + */ + ADDRESS_LENGTH: 42 as const, + + /** + * Salt byte length without 0x prefix (32 bytes). + * + * @remarks + * Raw byte count used for validation + * Equivalent to 64 hex characters + */ + SALT_BYTE_LENGTH: 32 as const, +} as const; + +/** + * Standardized error messages for validation failures. + * + * Provides consistent, user-friendly error messages for common validation + * failures. All messages are type-safe constants using `as const` assertion. + * + * @remarks + * Message Categories: + * - **Address Validation**: Safe, owner, token, pool, factory, deployer + * - **Format Requirements**: Safe JSON required parameters + * - **Salt Validation**: Salt format and length requirements + * + * Usage Pattern: + * - Throw errors with these messages for consistency + * - Messages are descriptive for end users + * - Can be extended with context via CLIError technicalDetails + * + * Design Philosophy: + * - Clear, actionable messages + * - Consistent phrasing across error types + * - Include format hints where helpful + * + * @example + * ```typescript + * // Address validation + * if (!ethers.isAddress(tokenAddress)) { + * throw new CLIError( + * VALIDATION_ERRORS.INVALID_TOKEN_ADDRESS, + * { providedAddress: tokenAddress } + * ); + * } + * ``` + * + * @example + * ```typescript + * // Salt validation + * if (salt.length !== VALIDATION_RULES.SALT_LENGTH) { + * throw new CLIError( + * VALIDATION_ERRORS.SALT_REQUIRED, + * { providedSalt: salt, expectedLength: VALIDATION_RULES.SALT_LENGTH } + * ); + * } + * ``` + * + * @example + * ```typescript + * // Safe JSON format validation + * if (format === 'safe-json' && !options.chainId) { + * throw new CLIError(VALIDATION_ERRORS.MISSING_SAFE_JSON_PARAMS); + * } + * ``` + * + * @public + */ +export const VALIDATION_ERRORS = { + /** Error when Safe multisig address is invalid or malformed */ + INVALID_SAFE_ADDRESS: 'Invalid Safe address', + + /** Error when Safe owner address is invalid or malformed */ + INVALID_OWNER_ADDRESS: 'Invalid owner address', + + /** Error when token contract address is invalid or malformed */ + INVALID_TOKEN_ADDRESS: 'Invalid token address', + + /** Error when pool contract address is invalid or malformed */ + INVALID_POOL_ADDRESS: 'Invalid pool address', + + /** Error when factory contract address is invalid or malformed */ + INVALID_FACTORY_ADDRESS: 'Invalid factory address', + + /** Error when deployer address is invalid or malformed */ + INVALID_DEPLOYER_ADDRESS: 'Invalid deployer address', + + /** + * Error when Safe Transaction Builder JSON format requires missing parameters. + * + * @remarks + * Required parameters: chainId, safe (address), owner (address) + */ + MISSING_SAFE_JSON_PARAMS: + 'chainId, safe, and owner are required for Safe Transaction Builder JSON format', + + /** + * Error when salt is missing or has incorrect format/length. + * + * @remarks + * Expected format: 0x followed by 64 hexadecimal characters (32 bytes total) + */ + SALT_REQUIRED: + 'Salt is required and must be 32 bytes (66 characters: 0x followed by 64 hex digits)', +} as const; diff --git a/src/constants/bytecodes.ts b/src/constants/bytecodes.ts index 2b46f2d..29da1c0 100644 --- a/src/constants/bytecodes.ts +++ b/src/constants/bytecodes.ts @@ -1,9 +1,77 @@ /** - * Contract bytecodes for deployment - * These bytecodes are obtained from compiling the contracts: - * - BurnMintERC20: Chainlink's BurnMintERC20 token implementation - * - BurnMintTokenPool: Token pool implementation with burn/mint capability - * - LockReleaseTokenPool: Token pool implementation with lock/release capability + * @fileoverview Compiled contract bytecodes for CREATE2 deployments. + * + * This module contains compiled bytecodes for Chainlink CCIP TokenPool contracts + * used in factory-based CREATE2 deployments. Bytecodes are obtained from compiling + * Solidity contracts with specific compiler settings and optimization flags. + * + * Contract Versions: + * All contracts are version 1.5.1 + * + * Included Contracts: + * - **FACTORY_BURN_MINT_ERC20**: Chainlink's BurnMintERC20 token implementation + * - Mintable and burnable ERC20 token + * - Used with BurnMintTokenPool for burn/mint cross-chain transfers + * - Supports role-based access control for minters and burners + * + * - **BURN_MINT_TOKEN_POOL**: Token pool with burn/mint capability + * - Burns tokens on source chain during cross-chain transfer + * - Mints tokens on destination chain + * - Requires mint/burn roles on the token contract + * + * - **LOCK_RELEASE_TOKEN_POOL**: Token pool with lock/release capability + * - Locks tokens on source chain during cross-chain transfer + * - Releases tokens from pool on destination chain + * - Does not require special roles on token contract + * + * Usage Context: + * These bytecodes are used for computing CREATE2 deterministic addresses + * before deployment. The bytecodes are combined with constructor arguments + * and deployed via TokenPoolFactory. + * + * @example + * ```typescript + * import { BYTECODES } from './constants'; + * + * // Compute deterministic token address + * const tokenAddress = addressComputer.computeCreate2Address( + * factoryAddress, + * BYTECODES.FACTORY_BURN_MINT_ERC20, + * salt, + * senderAddress + * ); + * ``` + * + * @example + * ```typescript + * // Use in token deployment generator + * const tokenBytecode = BYTECODES.FACTORY_BURN_MINT_ERC20; + * const poolBytecode = BYTECODES.BURN_MINT_TOKEN_POOL; + * + * // Compute both addresses before deployment + * const tokenAddr = computeCreate2Address(factory, tokenBytecode, salt, sender); + * const poolAddr = computeCreate2Address(factory, poolBytecode, salt, sender); + * ``` + * + * @remarks + * Compiler Settings: + * - Solidity version: 0.8.24 + * - Optimization: Enabled + * - Runs: 200 + * - EVM version: Paris + * + * Bytecode Stability: + * - These bytecodes are version-specific + * - Any change in contract code or compiler settings will produce different bytecodes + * - DO NOT modify these values manually + * - Update only when new contract versions are released + * + * Contract Sources: + * - Chainlink CCIP contracts repository + * - https://github.com/smartcontractkit/ccip + * + * @module constants/bytecodes + * @public */ export const BYTECODES = { FACTORY_BURN_MINT_ERC20: diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts new file mode 100644 index 0000000..12ee095 --- /dev/null +++ b/src/constants/defaults.ts @@ -0,0 +1,46 @@ +/** + * @fileoverview Default value constants. + * + * This module defines default values for transactions and Safe multisig operations. + * Provides immutable constants used throughout the application for transaction + * generation and output formatting. + * + * @deprecated This module is being phased out in favor of `src/config/defaults.ts`. + * New code should import from `@/config` instead of `@/constants`. + * + * @module constants/defaults + * @see {@link module:config/defaults} for current configuration module + */ + +/** + * Default values for transactions and operations. + * + * @deprecated Use `DEFAULTS` from `src/config/defaults.ts` instead. + * + * @public + */ +export const DEFAULT_VALUES = { + /** Default transaction value (0 ETH) */ + TRANSACTION_VALUE: '0', + + /** Safe Transaction Builder JSON version */ + SAFE_TX_VERSION: '1.0', + + /** Default CLI output format */ + OUTPUT_FORMAT: 'calldata' as const, +} as const; + +/** + * Safe multisig operation type constants. + * + * @deprecated Use `SAFE_OPERATION` from `src/config/defaults.ts` instead. + * + * @public + */ +export const SAFE_OPERATION_TYPE = { + /** Standard contract call (operation type 0) */ + CALL: 0, + + /** Delegatecall operation (operation type 1) */ + DELEGATE_CALL: 1, +} as const; diff --git a/src/constants/errors.ts b/src/constants/errors.ts new file mode 100644 index 0000000..d89aa0b --- /dev/null +++ b/src/constants/errors.ts @@ -0,0 +1,53 @@ +/** + * @fileoverview Error message constants. + * + * This module defines standardized error messages for validation failures. + * Provides consistent, user-friendly error messages used throughout the application. + * + * @deprecated This module is being phased out in favor of `src/config/validation.ts`. + * New code should import `VALIDATION_ERRORS` from `@/config` instead. + * + * @module constants/errors + * @see {@link module:config/validation} for current validation configuration + */ + +/** + * Standardized error messages for validation failures. + * + * @deprecated Use `VALIDATION_ERRORS` from `src/config/validation.ts` instead. + * + * @public + */ +export const ERROR_MESSAGES = { + /** Error when Safe multisig address is invalid or malformed */ + INVALID_SAFE_ADDRESS: 'Invalid Safe address', + + /** Error when Safe owner address is invalid or malformed */ + INVALID_OWNER_ADDRESS: 'Invalid owner address', + + /** Error when token contract address is invalid or malformed */ + INVALID_TOKEN_ADDRESS: 'Invalid token address', + + /** Error when pool contract address is invalid or malformed */ + INVALID_POOL_ADDRESS: 'Invalid pool address', + + /** Error when factory contract address is invalid or malformed */ + INVALID_FACTORY_ADDRESS: 'Invalid factory address', + + /** Error when deployer address is invalid or malformed */ + INVALID_DEPLOYER_ADDRESS: 'Invalid deployer address', + + /** + * Error when Safe Transaction Builder JSON format requires missing parameters. + * Required: chainId, safe (address), owner (address) + */ + MISSING_SAFE_JSON_PARAMS: + 'chainId, safe, and owner are required for Safe Transaction Builder JSON format', + + /** + * Error when salt is missing or has incorrect format/length. + * Expected: 0x followed by 64 hexadecimal characters (32 bytes) + */ + SALT_REQUIRED: + 'Salt is required and must be 32 bytes (66 characters: 0x followed by 64 hex digits)', +} as const; diff --git a/src/constants/formats.ts b/src/constants/formats.ts new file mode 100644 index 0000000..dbc932b --- /dev/null +++ b/src/constants/formats.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview Output format and validation constants. + * + * This module defines output format options and validation rules for CLI commands. + * Provides constants for format validation and length constraints. + * + * @deprecated This module is being phased out in favor of: + * - `src/config/defaults.ts` for OUTPUT_FORMAT + * - `src/config/validation.ts` for VALIDATION_RULES + * New code should import from `@/config` instead. + * + * @module constants/formats + * @see {@link module:config/defaults} for output format configuration + * @see {@link module:config/validation} for validation rules + */ + +/** + * Output format option constants. + * + * @deprecated Use `OUTPUT_FORMAT` from `src/config/defaults.ts` instead. + * + * @public + */ +export const OUTPUT_FORMATS = { + /** Raw hex-encoded calldata format */ + CALLDATA: 'calldata', + + /** Safe Transaction Builder JSON format */ + SAFE_JSON: 'safe-json', +} as const; + +/** + * Validation rules for blockchain inputs. + * + * @deprecated Use `VALIDATION_RULES` from `src/config/validation.ts` instead. + * + * @public + */ +export const VALIDATION = { + /** Salt string length including 0x prefix (66 characters) */ + SALT_LENGTH: 66, + + /** Ethereum address string length including 0x prefix (42 characters) */ + ADDRESS_LENGTH: 42, +} as const; diff --git a/src/constants/index.ts b/src/constants/index.ts index 6f89217..e2b225b 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,34 @@ +/** + * @fileoverview Constants module barrel export. + * + * This module re-exports all application constants for convenient single-import + * access. Provides centralized access to bytecodes, default values, error messages, + * output formats, and validation rules. + * + * Exported Categories: + * - **Bytecodes** (from bytecodes): Contract bytecodes for factory deployments + * - **Defaults** (from defaults): Transaction and operation defaults + * - **Errors** (from errors): Standardized error messages + * - **Formats** (from formats): Output formats and validation rules + * + * @example + * ```typescript + * import { + * BYTECODES, + * DEFAULT_VALUES, + * ERROR_MESSAGES, + * OUTPUT_FORMATS + * } from './constants'; + * + * const bytecode = BYTECODES.FactoryBurnMintERC20; + * const format = OUTPUT_FORMATS.CALLDATA; + * const error = ERROR_MESSAGES.INVALID_TOKEN_ADDRESS; + * ``` + * + * @module constants + */ + export * from './bytecodes'; +export * from './defaults'; +export * from './errors'; +export * from './formats'; diff --git a/src/errors/AsyncErrorHandler.ts b/src/errors/AsyncErrorHandler.ts new file mode 100644 index 0000000..79af8f5 --- /dev/null +++ b/src/errors/AsyncErrorHandler.ts @@ -0,0 +1,458 @@ +/** + * @fileoverview Async error handling utilities for consistent error patterns. + * + * This module provides utility functions for handling asynchronous operations with + * consistent error handling, logging, and retry logic. Implements common patterns + * like Result types, error wrapping, and exponential backoff retries. + * + * Key Functions: + * - `wrapAsync()`: Returns Result instead of throwing (Result pattern) + * - `executeAsync()`: Throws typed errors with context (exception pattern) + * - `wrapError()`: Converts unknown errors to Error instances + * - `logError()`: Structured logging for errors + * - `retryAsync()`: Exponential backoff retry logic + * + * @module errors/AsyncErrorHandler + */ + +import { BaseError, ErrorContext } from './BaseError'; +import logger from '../utils/logger'; +import { setTimeout } from 'timers/promises'; + +/** + * Result type for operations that can fail (Result pattern). + * + * @remarks + * Discriminated union representing success or failure without using exceptions. + * Preferred over try/catch when calling code should handle errors explicitly. + * + * Success Case: + * - `success: true` discriminant + * - `data: T` contains the operation result + * + * Failure Case: + * - `success: false` discriminant + * - `error: E` contains the error (defaults to Error) + * + * @example + * ```typescript + * const result: Result = { success: true, data: 'Hello' }; + * if (result.success) { + * console.log(result.data); // TypeScript knows data exists + * } else { + * console.error(result.error); // TypeScript knows error exists + * } + * ``` + * + * @typeParam T - Type of successful result data + * @typeParam E - Type of error (defaults to Error) + * @public + */ +export type Result = { success: true; data: T } | { success: false; error: E }; + +/** + * Wraps an async operation with standardized error handling (Result pattern). + * + * Executes an async operation and returns a Result instead of throwing. + * Catches all errors and wraps them in a consistent Error format. + * + * @param operation - The async operation to execute + * @param errorMessage - User-friendly error message (prepended to original error) + * @param context - Optional contextual data for debugging + * @returns Promise> - Success with data or failure with error + * + * @remarks + * Use Cases: + * - When calling code should explicitly handle both success and failure + * - For operations where errors are expected and should be handled gracefully + * - When you want to avoid try/catch blocks in calling code + * + * Error Handling: + * - Catches all exceptions from the operation + * - Uses wrapError() to convert unknown errors to Error instances + * - Preserves original error information via error chaining + * - Returns Result with success=false and wrapped error + * + * @example + * ```typescript + * // Without wrapAsync (try/catch pattern) + * try { + * const data = await generateTokenDeployment(params); + * console.log('Success:', data); + * } catch (error) { + * console.error('Failed:', error); + * } + * + * // With wrapAsync (Result pattern) + * const result = await wrapAsync( + * () => generateTokenDeployment(params), + * 'Token deployment generation failed', + * { params } + * ); + * + * if (result.success) { + * console.log('Success:', result.data); + * } else { + * console.error('Failed:', result.error.message); + * } + * ``` + * + * @example + * ```typescript + * // With context for debugging + * const result = await wrapAsync( + * () => ethers.getAddress(addressInput), + * 'Invalid address format', + * { + * address: addressInput, + * field: 'tokenAddress', + * source: 'user input' + * } + * ); + * + * if (!result.success) { + * logger.error('Address validation failed', { error: result.error }); + * } + * ``` + * + * @see {@link executeAsync} for exception-based pattern + * @public + */ +export async function wrapAsync( + operation: () => Promise, + errorMessage: string, + context?: ErrorContext, +): Promise> { + try { + const data = await operation(); + return { success: true, data }; + } catch (error) { + const wrappedError = wrapError(error, errorMessage, context); + return { success: false, error: wrappedError }; + } +} + +/** + * Wraps an async operation and throws typed errors on failure (exception pattern). + * + * Executes an async operation and throws a specific error class on failure. + * Preserves original error as cause for error chaining. + * + * @param operation - The async operation to execute + * @param ErrorClass - Error class constructor to instantiate on failure + * @param errorMessage - Human-readable error message + * @param context - Optional contextual data for debugging + * @returns Promise - Result data on success + * @throws {E} Typed error on failure (extends BaseError) + * + * @remarks + * Use Cases: + * - When errors should propagate up the call stack + * - For operations where failure is exceptional (not expected) + * - When you want consistent error types across layers + * + * Error Handling: + * - Catches all exceptions from the operation + * - Instantiates ErrorClass with message, context, and cause + * - Preserves original error as `cause` field (if it's an Error instance) + * - Throws typed error that can be caught by type + * + * @example + * ```typescript + * import { TokenDeploymentError } from './GeneratorErrors'; + * + * // Throws TokenDeploymentError on failure + * async function deployToken(params: TokenDeploymentInput) { + * return executeAsync( + * () => generateDeploymentCalldata(params), + * TokenDeploymentError, + * 'Failed to generate token deployment calldata', + * { deployer: params.deployer, salt: params.salt } + * ); + * } + * + * // Calling code can catch typed errors + * try { + * await deployToken(params); + * } catch (error) { + * if (error instanceof TokenDeploymentError) { + * console.error('Deployment error:', error.code, error.context); + * } + * } + * ``` + * + * @example + * ```typescript + * // With error chaining + * try { + * const address = await executeAsync( + * () => ethers.getAddress(input), + * ValidationError, + * 'Invalid Ethereum address', + * { input, field: 'tokenAddress' } + * ); + * } catch (error) { + * if (error instanceof ValidationError) { + * console.error(error.message); // "Invalid Ethereum address" + * console.error(error.cause); // Original ethers error + * } + * } + * ``` + * + * @see {@link wrapAsync} for Result-based pattern + * @typeParam T - Type of operation result + * @typeParam E - Error type (must extend BaseError) + * @public + */ +export async function executeAsync( + operation: () => Promise, + ErrorClass: new (message: string, context?: ErrorContext, cause?: Error) => E, + errorMessage: string, + context?: ErrorContext, +): Promise { + try { + return await operation(); + } catch (error) { + const cause = error instanceof Error ? error : undefined; + throw new ErrorClass(errorMessage, context, cause); + } +} + +/** + * Converts unknown errors to proper Error instances with context. + * + * Ensures all caught errors are Error instances with proper stack traces and + * error chaining. Used internally by wrapAsync() and executeAsync(). + * + * @param error - The unknown error to wrap (could be Error, string, object, etc.) + * @param message - User-friendly error message + * @param context - Optional contextual data (logged for non-Error values) + * @returns Error instance with proper message and stack trace + * + * @remarks + * Handling Logic: + * - **Error instance**: Enhances with message prefix, preserves stack, sets cause + * - **Non-Error value**: Creates new Error, logs warning with context + * + * Error Enhancement (when error is already Error): + * - Prepends message: `${message}: ${error.message}` + * - Preserves original stack trace + * - Sets original error as `cause` field + * + * Non-Error Handling: + * - Creates new Error with message only + * - Logs warning about non-Error value being thrown + * - Includes context in log for debugging + * + * @example + * ```typescript + * try { + * throw new Error('Original error'); + * } catch (error) { + * const wrapped = wrapError(error, 'Operation failed'); + * console.log(wrapped.message); + * // "Operation failed: Original error" + * console.log(wrapped.cause); + * // Original Error instance + * } + * ``` + * + * @example + * ```typescript + * // Non-Error value (bad practice but handled) + * try { + * throw 'string error'; + * } catch (error) { + * const wrapped = wrapError(error, 'Failed', { operation: 'test' }); + * console.log(wrapped.message); // "Failed" + * // Logs warning: "Non-Error value caught" + * } + * ``` + * + * @public + */ +export function wrapError(error: unknown, message: string, context?: ErrorContext): Error { + if (error instanceof Error) { + // Enhance existing error with context + const enhancedError = new Error(`${message}: ${error.message}`); + if (error.stack !== undefined) enhancedError.stack = error.stack; + enhancedError.cause = error; + return enhancedError; + } + + // Create new error for non-Error values + if (context) { + logger.error('Non-Error value caught', { error, context }); + } + return new Error(message); +} + +/** + * Logs error with structured format based on error type. + * + * Provides consistent error logging across the application. Automatically + * extracts appropriate fields from different error types. + * + * @param error - The error to log (BaseError, Error, or unknown) + * @param operation - Name of the operation that failed (e.g., 'Token deployment') + * @param context - Optional additional context for the log + * + * @remarks + * Logging Behavior by Error Type: + * - **BaseError**: Uses toJSON() for full serialization (code, context, cause, stack) + * - **Error**: Logs name, message, stack, and context + * - **Unknown**: Logs as unknown error with raw value and context + * + * Log Format: + * - Log level: `error` (for BaseError and Error) or `error` (for unknown) + * - Message: `${operation} failed` or `${operation} failed with unknown error` + * - Structured data includes all relevant error fields + * + * @example + * ```typescript + * try { + * await generateTokenDeployment(params); + * } catch (error) { + * logError(error, 'Token deployment generation', { + * deployer: params.deployer, + * salt: params.salt + * }); + * } + * + * // For BaseError, logs: + * // { + * // message: "Token deployment generation failed", + * // name: "TokenDeploymentError", + * // code: "GENERATION_FAILED", + * // context: { deployer: "0x...", salt: "0x..." }, + * // additionalContext: { ... } + * // } + * ``` + * + * @public + */ +export function logError(error: unknown, operation: string, context?: ErrorContext): void { + if (error instanceof BaseError) { + logger.error(`${operation} failed`, { + ...error.toJSON(), + additionalContext: context, + }); + } else if (error instanceof Error) { + logger.error(`${operation} failed`, { + name: error.name, + message: error.message, + stack: error.stack, + context, + }); + } else { + logger.error(`${operation} failed with unknown error`, { + error, + context, + }); + } +} + +/** + * Retries an async operation with exponential backoff. + * + * Executes an operation with automatic retry logic using exponential backoff. + * Logs warnings for each retry attempt. + * + * @param operation - The async operation to retry + * @param maxRetries - Maximum number of retries (default: 3) + * @param baseDelay - Base delay in milliseconds (default: 1000ms) + * @returns Promise - Result data on success + * @throws {Error} Last error if all retries exhausted + * + * @remarks + * Retry Strategy: + * - Attempts: Initial attempt + maxRetries (total: maxRetries + 1) + * - Delay: `baseDelay * 2^attempt` (exponential backoff) + * - Attempt 0: No delay + * - Attempt 1: baseDelay (e.g., 1000ms) + * - Attempt 2: baseDelay * 2 (e.g., 2000ms) + * - Attempt 3: baseDelay * 4 (e.g., 4000ms) + * - Logging: Warns on each retry with delay time and error message + * + * Use Cases: + * - Network requests that may fail transiently + * - RPC calls to blockchain nodes + * - File system operations with potential locks + * - Any operation with transient failure modes + * + * @example + * ```typescript + * // Retry RPC call with default settings (3 retries, 1s base delay) + * const balance = await retryAsync( + * () => provider.getBalance(address) + * ); + * + * // Timeline: + * // Attempt 0: Immediate + * // Attempt 1: After 1000ms + * // Attempt 2: After 2000ms + * // Attempt 3: After 4000ms + * // Total max time: 7 seconds + * ``` + * + * @example + * ```typescript + * // Custom retry settings + * const data = await retryAsync( + * () => fetchDataFromAPI(url), + * 5, // 5 retries (6 total attempts) + * 500 // 500ms base delay + * ); + * + * // Timeline: + * // Attempt 0: Immediate + * // Attempt 1: After 500ms + * // Attempt 2: After 1000ms + * // Attempt 3: After 2000ms + * // Attempt 4: After 4000ms + * // Attempt 5: After 8000ms + * // Total max time: 15.5 seconds + * ``` + * + * @example + * ```typescript + * // With error handling + * try { + * const result = await retryAsync( + * () => unstableOperation(), + * 3, + * 1000 + * ); + * console.log('Success after retries:', result); + * } catch (error) { + * console.error('Failed after all retries:', error); + * } + * ``` + * + * @public + */ +export async function retryAsync( + operation: () => Promise, + maxRetries: number = 3, + baseDelay: number = 1000, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < maxRetries) { + const delay = baseDelay * Math.pow(2, attempt); + logger.warn(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms`, { + error: lastError.message, + }); + await setTimeout(delay); + } + } + } + + throw lastError || new Error('Operation failed after retries'); +} diff --git a/src/errors/BaseError.ts b/src/errors/BaseError.ts new file mode 100644 index 0000000..cba9d0f --- /dev/null +++ b/src/errors/BaseError.ts @@ -0,0 +1,255 @@ +/** + * @fileoverview Base error class with structured context and error chaining. + * + * This module provides the foundational error infrastructure for the application. + * All custom errors extend BaseError, which adds structured context, error codes, + * and cause chaining to standard JavaScript Error. + * + * Features: + * - Error codes for categorization and filtering + * - Contextual data for debugging (params, state, etc.) + * - Error chaining via `cause` field + * - JSON serialization for logging + * - Proper stack trace preservation + * + * Design Pattern: + * Abstract base class that should not be instantiated directly. Subclasses + * define specific error categories (validation, generation, etc.). + * + * @module errors/BaseError + */ + +/** + * Error context interface for additional debugging information. + * + * @remarks + * Flexible key-value structure for attaching any relevant data to errors. + * Common uses: + * - Function parameters that caused the error + * - Application state at time of error + * - IDs or addresses involved in the operation + * - Computed values that led to failure + * + * @example + * ```typescript + * const context: ErrorContext = { + * tokenAddress: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + * receiverAddress: '0x1234567890123456789012345678901234567890', + * amount: '1000000000000000000000', + * operation: 'mint' + * }; + * ``` + * + * @public + */ +export interface ErrorContext { + [key: string]: unknown; +} + +/** + * Abstract base error class with structured context and error chaining. + * + * @remarks + * Foundation for all custom errors in the application. Extends standard Error + * with additional fields for better error handling, debugging, and logging. + * + * Features: + * - **Error Code**: String code for categorization (e.g., 'INVALID_INPUT') + * - **Context**: Key-value data structure with debugging information + * - **Cause**: Original error that triggered this error (error chaining) + * - **Stack Trace**: Proper stack trace preservation + * - **JSON Serialization**: toJSON() method for logging and transport + * + * Abstract Class: + * - Cannot be instantiated directly + * - Must be extended by concrete error classes + * - Subclasses define specific error categories + * + * @example + * ```typescript + * // Implementing a concrete error class + * export class ValidationError extends BaseError { + * constructor(message: string, context?: ErrorContext, cause?: Error) { + * super(message, 'VALIDATION_ERROR', context, cause); + * } + * } + * ``` + * + * @example + * ```typescript + * // Using with context + * throw new ValidationError( + * 'Invalid token address format', + * { + * address: '0xinvalid', + * field: 'tokenAddress', + * expectedFormat: '0x followed by 40 hex characters' + * } + * ); + * ``` + * + * @example + * ```typescript + * // Error chaining with cause + * try { + * await someOperation(); + * } catch (error) { + * throw new GenerationError( + * 'Failed to generate token deployment', + * { operation: 'tokenDeployment' }, + * error as Error // Original error as cause + * ); + * } + * ``` + * + * @public + */ +export abstract class BaseError extends Error { + /** + * Error code for categorization and filtering. + * + * @remarks + * Used for: + * - Programmatic error handling (switch/if statements) + * - Error filtering in logs + * - Error metrics and monitoring + * - User-facing error messages + */ + public readonly code: string; + + /** + * Additional contextual data for debugging. + * + * @remarks + * Optional key-value structure with any relevant debugging information. + * Included in JSON serialization for logging. + */ + public readonly context?: ErrorContext; + + /** + * Original error that caused this error (error chaining). + * + * @remarks + * Preserves the original error when wrapping or re-throwing. Enables + * error chain inspection for debugging. + */ + public readonly cause?: Error; + + /** + * Creates a new BaseError instance. + * + * @param message - Human-readable error message + * @param code - Error code for categorization + * @param context - Optional contextual data for debugging + * @param cause - Optional original error that caused this error + * + * @remarks + * Constructor Behavior: + * - Sets error name to class name (e.g., 'ValidationError') + * - Assigns error code for categorization + * - Stores optional context and cause + * - Captures stack trace at throw location + * + * Stack Trace: + * - Uses Error.captureStackTrace() for proper stack traces + * - Excludes constructor from stack trace + * - Shows exact location where error was thrown + * + * @protected + */ + constructor(message: string, code: string, context?: ErrorContext, cause?: Error) { + super(message); + this.name = this.constructor.name; + this.code = code; + if (context !== undefined) this.context = context; + if (cause !== undefined) this.cause = cause; + + // Maintains proper stack trace for where our error was thrown + Error.captureStackTrace(this, this.constructor); + } + + /** + * Serializes error to JSON for logging and transport. + * + * @returns JSON-serializable object with all error properties + * + * @remarks + * Included Fields: + * - `name`: Error class name (e.g., 'TokenDeploymentError') + * - `code`: Error code (e.g., 'GENERATION_FAILED') + * - `message`: Error message + * - `context`: Contextual data (if provided) + * - `stack`: Stack trace string + * - `cause`: Serialized original error (if provided) + * + * Cause Serialization: + * - If cause exists, serializes name, message, and stack + * - Prevents circular reference issues + * - Preserves error chain information + * + * Use Cases: + * - Winston/Pino logging + * - Error reporting to monitoring services + * - HTTP response bodies + * - Error storage in databases + * + * @example + * ```typescript + * try { + * throw new TokenDeploymentError( + * 'Failed to deploy token', + * { deployer: '0x1234...', salt: '0x5678...' } + * ); + * } catch (error) { + * const json = (error as BaseError).toJSON(); + * console.log(JSON.stringify(json, null, 2)); + * // { + * // "name": "TokenDeploymentError", + * // "code": "GENERATION_FAILED", + * // "message": "Failed to deploy token", + * // "context": { + * // "deployer": "0x1234...", + * // "salt": "0x5678..." + * // }, + * // "stack": "TokenDeploymentError: Failed to deploy token\n at ...", + * // "cause": undefined + * // } + * } + * ``` + * + * @example + * ```typescript + * // With error chaining + * try { + * await ethers.getAddress('invalid'); + * } catch (originalError) { + * const error = new ValidationError( + * 'Invalid address format', + * { address: 'invalid' }, + * originalError as Error + * ); + * + * const json = error.toJSON(); + * // json.cause contains serialized originalError + * } + * ``` + * + * @public + */ + toJSON(): Record { + return { + name: this.name, + code: this.code, + message: this.message, + context: this.context, + stack: this.stack, + cause: this.cause + ? { + name: this.cause.name, + message: this.cause.message, + stack: this.cause.stack, + } + : undefined, + }; + } +} diff --git a/src/errors/GeneratorErrors.ts b/src/errors/GeneratorErrors.ts new file mode 100644 index 0000000..2c0f612 --- /dev/null +++ b/src/errors/GeneratorErrors.ts @@ -0,0 +1,396 @@ +/** + * @fileoverview Generator-specific error classes for CLI operations. + * + * This module defines error classes for all generator operations in the CLI. + * Each generator function (token deployment, pool deployment, chain update, etc.) + * has its own error class for precise error categorization and handling. + * + * Error Hierarchy: + * ``` + * BaseError (abstract) + * └─ GeneratorError (base for all generator errors) + * ├─ TokenDeploymentError + * ├─ PoolDeploymentError + * ├─ ChainUpdateError + * ├─ TokenMintError + * ├─ RoleManagementError + * ├─ AllowListUpdatesError + * └─ RateLimiterConfigError + * ``` + * + * All errors include: + * - Error code (from GeneratorErrorCode enum) + * - Human-readable message + * - Optional context for debugging + * - Optional cause (original error) + * + * @module errors/GeneratorErrors + * @see {@link BaseError} for base error infrastructure + */ + +import { BaseError, ErrorContext } from './BaseError'; + +/** + * Error codes for generator operations. + * + * @remarks + * Categories: + * - **Validation**: Input validation failures (INVALID_INPUT, INVALID_ADDRESS, INVALID_PARAMETER) + * - **Generation**: Transaction generation failures (GENERATION_FAILED, ENCODING_FAILED, ADDRESS_COMPUTATION_FAILED) + * - **Chain Types**: Cross-chain operation failures (UNSUPPORTED_CHAIN_TYPE, CHAIN_CONVERSION_FAILED) + * + * Use Cases: + * - Programmatic error handling in CLI commands + * - Error filtering and categorization in logs + * - Error metrics and monitoring dashboards + * - User-facing error messages + * + * @example + * ```typescript + * // Using error codes for handling + * try { + * await generateTokenDeployment(params); + * } catch (error) { + * if (error instanceof GeneratorError) { + * switch (error.code) { + * case GeneratorErrorCode.INVALID_INPUT: + * console.error('Invalid input parameters'); + * break; + * case GeneratorErrorCode.ENCODING_FAILED: + * console.error('Failed to encode transaction data'); + * break; + * default: + * console.error('Unknown generator error'); + * } + * } + * } + * ``` + * + * @public + */ +export enum GeneratorErrorCode { + /** Input validation failed (malformed JSON, missing fields, etc.) */ + INVALID_INPUT = 'INVALID_INPUT', + + /** Invalid Ethereum address format */ + INVALID_ADDRESS = 'INVALID_ADDRESS', + + /** Invalid parameter value (out of range, wrong type, etc.) */ + INVALID_PARAMETER = 'INVALID_PARAMETER', + + /** Transaction generation failed (generic generation error) */ + GENERATION_FAILED = 'GENERATION_FAILED', + + /** Failed to encode transaction data (ABI encoding error) */ + ENCODING_FAILED = 'ENCODING_FAILED', + + /** Failed to compute CREATE2 address */ + ADDRESS_COMPUTATION_FAILED = 'ADDRESS_COMPUTATION_FAILED', + + /** Unsupported chain type (e.g., MVM not yet implemented) */ + UNSUPPORTED_CHAIN_TYPE = 'UNSUPPORTED_CHAIN_TYPE', + + /** Failed to convert chain-specific address format */ + CHAIN_CONVERSION_FAILED = 'CHAIN_CONVERSION_FAILED', +} + +/** + * Base error class for all generator operations. + * + * @remarks + * Extends BaseError with generator-specific error codes. All generator-specific + * errors (TokenDeploymentError, PoolDeploymentError, etc.) extend this class. + * + * Typically not thrown directly - use specific error classes for each operation + * type for better error categorization. + * + * @example + * ```typescript + * // Used as base class for specific errors + * export class TokenDeploymentError extends GeneratorError { + * constructor(message: string, context?: ErrorContext, cause?: Error) { + * super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + * } + * } + * ``` + * + * @example + * ```typescript + * // Can be used for instanceof checks + * try { + * await generateTokenDeployment(params); + * } catch (error) { + * if (error instanceof GeneratorError) { + * // Handle any generator error + * console.error(`Generator error: ${error.code}`); + * } + * } + * ``` + * + * @public + */ +export class GeneratorError extends BaseError { + /** + * Creates a new GeneratorError instance. + * + * @param message - Human-readable error message + * @param code - Error code from GeneratorErrorCode enum + * @param context - Optional contextual data for debugging + * @param cause - Optional original error that caused this error + */ + constructor(message: string, code: GeneratorErrorCode, context?: ErrorContext, cause?: Error) { + super(message, code, context, cause); + } +} + +/** + * Error for token deployment operations. + * + * @remarks + * Thrown when token deployment generation fails. Covers both token+pool deployment + * (`generate-token-deployment` command) and any related failures during the process. + * + * Common Causes: + * - Invalid token parameters (name, symbol, decimals) + * - Invalid pool parameters (pool type, initial config) + * - Encoding failures for TokenPoolFactory calls + * - CREATE2 address computation failures + * + * @example + * ```typescript + * throw new TokenDeploymentError( + * 'Failed to compute token address', + * { + * deployer: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * salt: '0x0000000000000000000000000000000000000000000000000000000123456789' + * } + * ); + * ``` + * + * @public + */ +export class TokenDeploymentError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} + +/** + * Error for pool deployment operations. + * + * @remarks + * Thrown when pool deployment generation fails. Covers pool-only deployment + * (`generate-pool-deployment` command) for existing tokens. + * + * Common Causes: + * - Invalid pool type (must be BurnMintTokenPool or LockReleaseTokenPool) + * - Invalid pool configuration parameters + * - Encoding failures for TokenPoolFactory.deployPool calls + * - Token address validation failures + * + * @example + * ```typescript + * throw new PoolDeploymentError( + * 'Invalid pool type specified', + * { + * poolType: 'InvalidType', + * expectedTypes: ['BurnMintTokenPool', 'LockReleaseTokenPool'] + * } + * ); + * ``` + * + * @public + */ +export class PoolDeploymentError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} + +/** + * Error for chain update operations. + * + * @remarks + * Thrown when cross-chain configuration generation fails. Covers adding/removing + * remote chains (`generate-chain-update` command) for TokenPool contracts. + * + * Common Causes: + * - Unsupported chain type (EVM, SVM supported; MVM not yet implemented) + * - Invalid remote token address format + * - Chain selector validation failures + * - Address encoding failures (EVM vs SVM address formats) + * + * @example + * ```typescript + * throw new ChainUpdateError( + * 'Unsupported chain type: MVM', + * { + * chainType: 'MVM', + * supportedTypes: ['EVM', 'SVM'] + * } + * ); + * ``` + * + * @public + */ +export class ChainUpdateError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} + +/** + * Error for token mint operations. + * + * @remarks + * Thrown when token minting transaction generation fails. Covers the + * `generate-mint` command for BurnMintERC20 tokens. + * + * Common Causes: + * - Invalid receiver address format + * - Invalid amount (must be numeric string, cannot be negative) + * - Token address validation failures + * - Encoding failures for mint() function calls + * + * @example + * ```typescript + * throw new TokenMintError( + * 'Invalid mint amount', + * { + * amount: '-1000', + * receiver: '0x1234567890123456789012345678901234567890' + * } + * ); + * ``` + * + * @public + */ +export class TokenMintError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} + +/** + * Error for role management operations. + * + * @remarks + * Thrown when role grant/revoke transaction generation fails. Covers the + * `generate-grant-roles` command for granting mint/burn roles to pools. + * + * Common Causes: + * - Invalid token or pool address format + * - Invalid role type (must be 'mint', 'burn', or 'both') + * - Encoding failures for grantRole/revokeRole calls + * - Missing role parameters + * + * @example + * ```typescript + * throw new RoleManagementError( + * 'Invalid role type specified', + * { + * roleType: 'invalid', + * expectedTypes: ['mint', 'burn', 'both'], + * tokenAddress: '0x779877...', + * poolAddress: '0x123456...' + * } + * ); + * ``` + * + * @public + */ +export class RoleManagementError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} + +/** + * Error for allow list update operations. + * + * @remarks + * Thrown when allow list configuration generation fails. Covers updating the + * allowed senders list for TokenPool contracts. + * + * Common Causes: + * - Invalid address format in allow list + * - Empty allow list when required + * - Pool address validation failures + * - Encoding failures for applyAllowListUpdates calls + * + * @example + * ```typescript + * throw new AllowListUpdatesError( + * 'Invalid address in allow list', + * { + * invalidAddress: '0xinvalid', + * poolAddress: '0x1234567890123456789012345678901234567890' + * } + * ); + * ``` + * + * @public + */ +export class AllowListUpdatesError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} + +/** + * Error for rate limiter configuration operations. + * + * @remarks + * Thrown when rate limiter configuration generation fails. Covers updating the + * token bucket rate limiter settings for TokenPool contracts. + * + * Common Causes: + * - Invalid capacity or rate values (must be numeric strings) + * - Negative capacity or rate values + * - Pool address validation failures + * - Encoding failures for setRateLimiterConfig calls + * + * @example + * ```typescript + * throw new RateLimiterConfigError( + * 'Invalid rate limiter capacity', + * { + * capacity: '-100', + * rate: '10', + * poolAddress: '0x1234567890123456789012345678901234567890' + * } + * ); + * ``` + * + * @public + */ +export class RateLimiterConfigError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} + +/** + * Error thrown during accept ownership transaction generation. + * + * Used when generating transactions to accept ownership of contracts using + * the two-step ownership transfer pattern. Common causes include invalid + * contract addresses. + * + * @example + * ```typescript + * throw new AcceptOwnershipError( + * 'Invalid contract address format', + * { + * contractAddress: '0xinvalid' + * } + * ); + * ``` + * + * @public + */ +export class AcceptOwnershipError extends GeneratorError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, GeneratorErrorCode.GENERATION_FAILED, context, cause); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..190db2f --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,58 @@ +/** + * @fileoverview Error handling module barrel export. + * + * This module re-exports all error handling infrastructure for the application. + * Provides access to base errors, generator-specific errors, and async error + * handling utilities. + * + * Exported Components: + * + * **Base Error Infrastructure** (from BaseError): + * - {@link BaseError} - Abstract base error class with context and error chaining + * - {@link ErrorContext} - Key-value interface for error context data + * + * **Generator Error Classes** (from GeneratorErrors): + * - {@link GeneratorError} - Base class for all generator errors + * - {@link GeneratorErrorCode} - Enum of error codes for categorization + * - {@link TokenDeploymentError} - Token deployment operation errors + * - {@link PoolDeploymentError} - Pool deployment operation errors + * - {@link ChainUpdateError} - Cross-chain configuration errors + * - {@link TokenMintError} - Token minting operation errors + * - {@link RoleManagementError} - Role grant/revoke operation errors + * - {@link AllowListUpdatesError} - Allow list configuration errors + * - {@link RateLimiterConfigError} - Rate limiter configuration errors + * + * **Async Error Handling Utilities** (from AsyncErrorHandler): + * - {@link Result} - Result type for operations (success/failure discriminated union) + * - {@link wrapAsync} - Wraps async operations returning Result (Result pattern) + * - {@link executeAsync} - Wraps async operations throwing typed errors (exception pattern) + * - {@link wrapError} - Converts unknown errors to Error instances + * - {@link logError} - Structured error logging + * - {@link retryAsync} - Exponential backoff retry logic + * + * @example + * ```typescript + * import { + * TokenDeploymentError, + * wrapAsync, + * logError + * } from './errors'; + * + * // Using Result pattern with wrapAsync + * const result = await wrapAsync( + * () => generateTokenDeployment(params), + * 'Token deployment failed', + * { deployer: params.deployer } + * ); + * + * if (!result.success) { + * logError(result.error, 'Token deployment'); + * } + * ``` + * + * @module errors + */ + +export * from './BaseError'; +export * from './GeneratorErrors'; +export * from './AsyncErrorHandler'; diff --git a/src/formatters/acceptOwnershipFormatter.ts b/src/formatters/acceptOwnershipFormatter.ts new file mode 100644 index 0000000..5daba8c --- /dev/null +++ b/src/formatters/acceptOwnershipFormatter.ts @@ -0,0 +1,107 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for accept ownership transactions. + * + * This module formats accept ownership transaction data into Safe Transaction Builder + * JSON format. Used for contracts with two-step ownership transfer pattern. + * + * @module formatters/acceptOwnershipFormatter + */ + +import { + SafeTransactionDataBase, + SafeTransactionBuilderJSON, + SafeMetadata, + SAFE_TX_BUILDER_VERSION, +} from '../types/safe'; +import { DEFAULTS } from '../config'; + +/** + * Metadata for accept ownership Safe transaction formatting. + * + * @public + */ +export interface SafeAcceptOwnershipMetadata extends SafeMetadata { + /** Address of the contract to accept ownership of */ + contractAddress: string; +} + +/** + * Formatter interface for accept ownership transactions to Safe JSON format. + * + * Converts raw accept ownership transaction data into the Safe Transaction Builder + * JSON format, including contract method signature and descriptive metadata. + * + * @public + */ +export interface AcceptOwnershipFormatter { + /** + * Formats accept ownership transaction to Safe Transaction Builder JSON. + * + * @param transaction - Transaction data from generator + * @param metadata - Safe metadata including contract address + * @returns Complete Safe Transaction Builder JSON ready for export + */ + format( + transaction: SafeTransactionDataBase, + metadata: SafeAcceptOwnershipMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates an accept ownership transaction formatter. + * + * Factory function that creates a formatter for converting accept ownership + * transactions into Safe Transaction Builder JSON format. + * + * @returns Formatter instance implementing {@link AcceptOwnershipFormatter} interface + * + * @example + * ```typescript + * const formatter = createAcceptOwnershipFormatter(); + * const safeJson = formatter.format(transaction, { + * chainId: "84532", + * safeAddress: "0xSafe...", + * ownerAddress: "0xOwner...", + * contractAddress: "0xPool..." + * }); + * ``` + * + * @public + */ +export function createAcceptOwnershipFormatter(): AcceptOwnershipFormatter { + return { + format( + transaction: SafeTransactionDataBase, + metadata: SafeAcceptOwnershipMetadata, + ): SafeTransactionBuilderJSON { + // acceptOwnership() has no inputs - build the JSON directly + // since we don't need an interface for a parameterless function + return { + version: DEFAULTS.SAFE_TX_VERSION, + chainId: metadata.chainId, + createdAt: Date.now(), + meta: { + name: `Accept Ownership - ${metadata.contractAddress.slice(0, 10)}...`, + description: `Accept ownership of contract ${metadata.contractAddress}`, + txBuilderVersion: SAFE_TX_BUILDER_VERSION, + createdFromSafeAddress: metadata.safeAddress, + createdFromOwnerAddress: metadata.ownerAddress, + }, + transactions: [ + { + to: transaction.to, + value: transaction.value, + data: transaction.data, + operation: transaction.operation, + contractMethod: { + inputs: [], + name: 'acceptOwnership', + payable: false, + }, + contractInputsValues: null, + }, + ], + }; + }, + }; +} diff --git a/src/formatters/allowListFormatter.ts b/src/formatters/allowListFormatter.ts new file mode 100644 index 0000000..3567497 --- /dev/null +++ b/src/formatters/allowListFormatter.ts @@ -0,0 +1,295 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for allow list update transactions. + * + * This module formats allow list management transaction data into Safe Transaction Builder + * JSON format. Handles atomic addition and removal of addresses from the TokenPool allow list, + * providing user-friendly summaries of the changes being applied. + * + * @module formatters/allowListFormatter + */ + +import { AllowListUpdatesInput, SafeAllowListMetadata } from '../types/allowList'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON } from '../types/safe'; +import { IInterfaceProvider } from '../interfaces'; +import { buildSafeTransactionJson } from '../utils/safeJsonBuilder'; + +/** + * Formatter interface for allow list update transactions to Safe JSON format. + * + * Converts raw allow list update transaction data into the Safe Transaction Builder + * JSON format, including contract method signatures, addresses to add/remove, and + * descriptive metadata for UI display. + * + * @remarks + * The formatter extracts the `applyAllowListUpdates` method fragment from the TokenPool + * interface and builds a complete Safe JSON structure with human-readable descriptions + * summarizing the number of addresses being added and removed. + * + * @public + */ +export interface AllowListFormatter { + /** + * Formats an allow list update transaction to Safe Transaction Builder JSON. + * + * @param transaction - Raw transaction data from the generator + * @param input - Allow list update input (addresses to remove and add) + * @param metadata - Safe metadata including token pool address (chain ID, Safe address, owner) + * + * @returns Complete Safe Transaction Builder JSON ready for export + * + * @see {@link SafeTransactionBuilderJSON} for output format structure + * @see {@link buildSafeTransactionJson} for JSON builder utility + */ + format( + transaction: SafeTransactionDataBase, + input: AllowListUpdatesInput, + metadata: SafeAllowListMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates an allow list update transaction formatter. + * + * Factory function that creates a formatter for converting allow list management + * transactions into Safe Transaction Builder JSON format. Handles atomic addition + * and removal of addresses from the TokenPool's access control allow list. + * + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPool) + * + * @returns Formatter instance implementing {@link AllowListFormatter} interface + * + * @remarks + * The formatter: + * 1. Extracts the `applyAllowListUpdates` method fragment from TokenPool interface + * 2. Builds descriptive transaction name ("Update Token Pool Allow List") + * 3. Creates human-readable description summarizing the changes: + * - "remove N address(es), add M address(es)" (both operations) + * - "remove N address(es)" (remove only) + * - "add N address(es)" (add only) + * - "no changes" (empty arrays) + * 4. Includes contract method signature with input types + * 5. Formats complete Safe JSON with metadata (chain ID, Safe address, owner, pool address) + * 6. Returns JSON ready for Safe Transaction Builder import + * + * Allow List Purpose: + * - Controls which addresses can initiate cross-chain token transfers through the pool + * - Provides additional security layer beyond ownership/role controls + * - Useful for restricting pool access to specific integrations or contracts + * - Empty allow list = unrestricted access (all addresses allowed) + * + * Operation Behavior: + * - **Atomic**: All additions and removals processed in single transaction + * - **Order**: Removals processed before additions + * - **Idempotent**: Safe to remove non-existent or add existing addresses + * + * @example + * ```typescript + * const formatter = createAllowListFormatter(interfaceProvider); + * + * // Format allow list update (add 2 addresses, remove 1) + * const transaction = await generator.generate(inputJson, poolAddress); + * const input: AllowListUpdatesInput = { + * removes: ["0xOldAddress"], + * adds: [ + * "0xNewIntegration1", + * "0xNewIntegration2" + * ] + * }; + * const metadata: SafeAllowListMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner", + * tokenPoolAddress: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * }; + * + * const safeJson = formatter.format(transaction, input, metadata); + * + * // Save to file for Safe Transaction Builder + * fs.writeFileSync('allow-list-update.json', JSON.stringify(safeJson, null, 2)); + * + * // JSON structure includes: + * console.log(safeJson.meta.name); // "Update Token Pool Allow List" + * console.log(safeJson.meta.description); // "Update allow list for token pool: remove 1 address, add 2 addresses" + * console.log(safeJson.transactions[0].to); // Pool address + * console.log(safeJson.transactions[0].contractMethod.name); // "applyAllowListUpdates" + * ``` + * + * @example + * ```typescript + * // Complete workflow: Deploy pool and configure allow list + * const poolGen = createPoolDeploymentGenerator(logger, interfaceProvider); + * const allowListGen = createAllowListUpdatesGenerator(logger, interfaceProvider); + * const allowListFormatter = createAllowListFormatter(interfaceProvider); + * + * // Step 1: Deploy pool + * const poolTx = await poolGen.generate( + * JSON.stringify({ + * token: "0xTokenAddress", + * decimals: 18, + * poolType: "BurnMintTokenPool", + * remoteTokenPools: [] + * }), + * factoryAddress, + * salt + * ); + * // Execute deployment and get pool address... + * + * // Step 2: Set initial allow list (add only) + * const allowListTx = await allowListGen.generate( + * JSON.stringify({ + * removes: [], + * adds: [ + * "0xTrustedIntegration1", + * "0xTrustedIntegration2" + * ] + * }), + * deployedPoolAddress + * ); + * + * // Step 3: Format for Safe + * const safeJson = allowListFormatter.format(allowListTx, { + * removes: [], + * adds: [ + * "0xTrustedIntegration1", + * "0xTrustedIntegration2" + * ] + * }, { + * chainId: "84532", + * safeAddress: safeAddress, + * ownerAddress: ownerAddress, + * tokenPoolAddress: deployedPoolAddress + * }); + * + * // Description: "Update allow list for token pool: add 2 addresses" + * fs.writeFileSync('allow-list-setup.json', JSON.stringify(safeJson, null, 2)); + * ``` + * + * @example + * ```typescript + * // Format remove-only operation (deprecate old integrations) + * const transaction = await generator.generate( + * JSON.stringify({ + * removes: [ + * "0xDeprecatedContract1", + * "0xDeprecatedContract2", + * "0xDeprecatedContract3" + * ], + * adds: [] + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * removes: [ + * "0xDeprecatedContract1", + * "0xDeprecatedContract2", + * "0xDeprecatedContract3" + * ], + * adds: [] + * }, metadata); + * + * // Description: "Update allow list for token pool: remove 3 addresses" + * ``` + * + * @example + * ```typescript + * // Format add-only operation (whitelist new integration) + * const transaction = await generator.generate( + * JSON.stringify({ + * removes: [], + * adds: ["0xNewDeFiProtocol"] + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * removes: [], + * adds: ["0xNewDeFiProtocol"] + * }, metadata); + * + * // Description: "Update allow list for token pool: add 1 address" + * ``` + * + * @example + * ```typescript + * // Format atomic update (swap old for new integrations) + * const transaction = await generator.generate( + * JSON.stringify({ + * removes: ["0xOldIntegrationV1", "0xOldIntegrationV2"], + * adds: ["0xNewIntegrationV3", "0xNewIntegrationV4", "0xNewIntegrationV5"] + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * removes: ["0xOldIntegrationV1", "0xOldIntegrationV2"], + * adds: ["0xNewIntegrationV3", "0xNewIntegrationV4", "0xNewIntegrationV5"] + * }, metadata); + * + * // Description: "Update allow list for token pool: remove 2 addresses, add 3 addresses" + * // Atomic: old addresses removed, new addresses added in single transaction + * ``` + * + * @example + * ```typescript + * // Disable allow list by removing all addresses (assume we know current list) + * const currentAllowList = ["0xAddr1", "0xAddr2", "0xAddr3"]; + * + * const transaction = await generator.generate( + * JSON.stringify({ + * removes: currentAllowList, + * adds: [] + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * removes: currentAllowList, + * adds: [] + * }, metadata); + * + * // Description: "Update allow list for token pool: remove 3 addresses" + * // Result: Empty allow list = unrestricted access + * ``` + * + * @see {@link AllowListFormatter} for interface definition + * @see {@link buildSafeTransactionJson} for JSON builder implementation + * @see {@link SafeTransactionBuilderJSON} for complete output format specification + * @see {@link SafeAllowListMetadata} for metadata structure including token pool address + * + * @public + */ +export function createAllowListFormatter( + interfaceProvider: IInterfaceProvider, +): AllowListFormatter { + return { + format( + transaction: SafeTransactionDataBase, + input: AllowListUpdatesInput, + metadata: SafeAllowListMetadata, + ): SafeTransactionBuilderJSON { + const removesCount = input.removes.length; + const addsCount = input.adds.length; + + let description = 'Update allow list for token pool: '; + if (removesCount > 0 && addsCount > 0) { + description += `remove ${removesCount} address${removesCount > 1 ? 'es' : ''}, add ${addsCount} address${addsCount > 1 ? 'es' : ''}`; + } else if (removesCount > 0) { + description += `remove ${removesCount} address${removesCount > 1 ? 'es' : ''}`; + } else if (addsCount > 0) { + description += `add ${addsCount} address${addsCount > 1 ? 'es' : ''}`; + } else { + description += 'no changes'; + } + + return buildSafeTransactionJson({ + transaction, + metadata, + name: 'Update Token Pool Allow List', + description, + contractInterface: interfaceProvider.getTokenPoolInterface(), + functionName: 'applyAllowListUpdates', + }); + }, + }; +} diff --git a/src/formatters/chainUpdateFormatter.ts b/src/formatters/chainUpdateFormatter.ts new file mode 100644 index 0000000..800b2a7 --- /dev/null +++ b/src/formatters/chainUpdateFormatter.ts @@ -0,0 +1,195 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for chain update transactions. + * + * This module formats cross-chain configuration update transaction data into Safe + * Transaction Builder JSON format. Handles adding and removing remote chain configurations + * with multi-chain address encoding support (EVM, SVM). + * + * @module formatters/chainUpdateFormatter + */ + +import { SafeChainUpdateMetadata } from '../types/chainUpdate'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON } from '../types/safe'; +import { IInterfaceProvider } from '../interfaces'; +import { buildSafeTransactionJson } from '../utils/safeJsonBuilder'; + +/** + * Formatter interface for chain update transactions to Safe JSON format. + * + * Converts raw chain update transaction data into the Safe Transaction Builder + * JSON format, including contract method signatures, chain configuration details, + * and descriptive metadata for UI display. + * + * @remarks + * The formatter extracts the `applyChainUpdates` method fragment from the TokenPool + * interface and builds a complete Safe JSON structure. It also sets the target address + * to the token pool address since the generator leaves it empty. + * + * @public + */ +export interface ChainUpdateFormatter { + /** + * Formats a chain update transaction to Safe Transaction Builder JSON. + * + * @param transaction - Raw transaction data from the generator (with empty 'to' field) + * @param metadata - Safe chain update metadata including token pool address + * + * @returns Complete Safe Transaction Builder JSON ready for export + * + * @see {@link SafeTransactionBuilderJSON} for output format structure + * @see {@link buildSafeTransactionJson} for JSON builder utility + */ + format( + transaction: SafeTransactionDataBase, + metadata: SafeChainUpdateMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates a chain update transaction formatter. + * + * Factory function that creates a formatter for converting cross-chain configuration + * update transactions into Safe Transaction Builder JSON format. Handles the complete + * chain update workflow including adding and removing chains. + * + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPool) + * + * @returns Formatter instance implementing {@link ChainUpdateFormatter} interface + * + * @remarks + * The formatter: + * 1. Extracts the `applyChainUpdates` method fragment from TokenPool interface + * 2. Sets the transaction target address to the token pool address from metadata + * 3. Builds descriptive transaction name and description for chain updates + * 4. Includes contract method signature with input/output types + * 5. Formats complete Safe JSON with metadata (chain ID, Safe address, owner, pool address) + * 6. Returns JSON ready for Safe Transaction Builder import + * + * Chain Update Operations: + * - **Add Chains**: Configure new remote chains with pool addresses, token addresses, and rate limiters + * - **Remove Chains**: Remove existing chain configurations by chain selector + * - **Atomic**: All additions and removals processed in a single transaction + * + * Target Address Handling: + * - Generator leaves `transaction.to` empty (since pool address varies) + * - Formatter fills in the pool address from metadata before building Safe JSON + * - This allows the same generated transaction to be used with different pools + * + * @example + * ```typescript + * const formatter = createChainUpdateFormatter(interfaceProvider); + * + * // Format chain update transaction for Safe + * const transaction = await generator.generate(inputJson); + * // Note: transaction.to is empty, filled by formatter + * + * const metadata: SafeChainUpdateMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner", + * tokenPoolAddress: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * }; + * + * const safeJson = formatter.format(transaction, metadata); + * + * // Save to file for Safe Transaction Builder + * fs.writeFileSync('chain-update.json', JSON.stringify(safeJson, null, 2)); + * + * // JSON structure includes: + * console.log(safeJson.meta.name); // "Token Pool Chain Updates" + * console.log(safeJson.meta.description); // "Apply chain updates to the Token Pool contract" + * console.log(safeJson.transactions[0].to); // Pool address (filled by formatter) + * console.log(safeJson.transactions[0].contractMethod.name); // "applyChainUpdates" + * ``` + * + * @example + * ```typescript + * // Complete workflow: Generate and format chain update + * const generator = createChainUpdateGenerator(logger, interfaceProvider); + * const formatter = createChainUpdateFormatter(interfaceProvider); + * + * // Step 1: Generate chain update transaction (add Ethereum Sepolia) + * const inputJson = JSON.stringify([ + * [], // No chains to remove + * [{ // Add Ethereum Sepolia + * remoteChainSelector: "16015286601757825753", + * remoteChainType: "evm", + * remotePoolAddresses: ["0x1234567890123456789012345678901234567890"], + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * outboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * }, + * inboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * } + * }] + * ]); + * + * const transaction = await generator.generate(inputJson); + * + * // Step 2: Format for Safe + * const safeJson = formatter.format(transaction, { + * chainId: "84532", // Base Sepolia + * safeAddress: safeAddress, + * ownerAddress: ownerAddress, + * tokenPoolAddress: poolAddress // Pool address filled here + * }); + * + * // Step 3: Export and import into Safe UI + * fs.writeFileSync('chain-update.json', JSON.stringify(safeJson, null, 2)); + * // Import this file in Safe Transaction Builder web UI + * ``` + * + * @example + * ```typescript + * // Format transaction that removes and adds chains + * const inputJson = JSON.stringify([ + * ["16015286601757825753"], // Remove Ethereum Sepolia + * [{ // Add Base Sepolia + * remoteChainSelector: "10344971235874465080", + * remoteChainType: "evm", + * remotePoolAddresses: ["0x5555555555555555555555555555555555555555"], + * remoteTokenAddress: "0x6666666666666666666666666666666666666666", + * outboundRateLimiterConfig: { isEnabled: true, capacity: "500000", rate: "50000" }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: "500000", rate: "50000" } + * }] + * ]); + * + * const transaction = await generator.generate(inputJson); + * const safeJson = formatter.format(transaction, metadata); + * // Description shows "Apply chain updates" (both add and remove) + * ``` + * + * @see {@link ChainUpdateFormatter} for interface definition + * @see {@link buildSafeTransactionJson} for JSON builder implementation + * @see {@link SafeTransactionBuilderJSON} for complete output format specification + * @see {@link SafeChainUpdateMetadata} for metadata structure including token pool address + * + * @public + */ +export function createChainUpdateFormatter( + interfaceProvider: IInterfaceProvider, +): ChainUpdateFormatter { + return { + format( + transaction: SafeTransactionDataBase, + metadata: SafeChainUpdateMetadata, + ): SafeTransactionBuilderJSON { + return buildSafeTransactionJson({ + transaction: { + ...transaction, + to: metadata.tokenPoolAddress, + }, + metadata, + name: 'Token Pool Chain Updates', + description: 'Apply chain updates to the Token Pool contract', + contractInterface: interfaceProvider.getTokenPoolInterface(), + functionName: 'applyChainUpdates', + }); + }, + }; +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts new file mode 100644 index 0000000..e6c73ec --- /dev/null +++ b/src/formatters/index.ts @@ -0,0 +1,21 @@ +/** + * Safe Transaction Builder JSON formatters + * Exports all formatter factory functions and types + */ + +export { createMintFormatter, type MintFormatter } from './mintFormatter'; +export { createChainUpdateFormatter, type ChainUpdateFormatter } from './chainUpdateFormatter'; +export { + createTokenDeploymentFormatter, + type TokenDeploymentFormatter, +} from './tokenDeploymentFormatter'; +export { + createPoolDeploymentFormatter, + type PoolDeploymentFormatter, +} from './poolDeploymentFormatter'; +export { + createRoleManagementFormatter, + type RoleManagementFormatter, +} from './roleManagementFormatter'; +export { createAllowListFormatter, type AllowListFormatter } from './allowListFormatter'; +export { createRateLimiterFormatter, type RateLimiterFormatter } from './rateLimiterFormatter'; diff --git a/src/formatters/mintFormatter.ts b/src/formatters/mintFormatter.ts new file mode 100644 index 0000000..63b99b3 --- /dev/null +++ b/src/formatters/mintFormatter.ts @@ -0,0 +1,240 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for token minting transactions. + * + * This module formats token minting transaction data into Safe Transaction Builder + * JSON format. Handles BurnMintERC20 token minting with receiver address and amount + * validation, providing user-friendly metadata for Safe UI display. + * + * @module formatters/mintFormatter + */ + +import { MintParams, SafeMintMetadata } from '../types/tokenMint'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON } from '../types/safe'; +import { IInterfaceProvider } from '../interfaces'; +import { buildSafeTransactionJson } from '../utils/safeJsonBuilder'; + +/** + * Formatter interface for token minting transactions to Safe JSON format. + * + * Converts raw token minting transaction data into the Safe Transaction Builder + * JSON format, including contract method signatures, mint parameters (receiver + * and amount), and descriptive metadata for UI display. + * + * @remarks + * The formatter extracts the `mint` method fragment from the BurnMintERC20 interface + * and builds a complete Safe JSON structure with human-readable descriptions including + * the mint amount and receiver address. + * + * @public + */ +export interface MintFormatter { + /** + * Formats a token minting transaction to Safe Transaction Builder JSON. + * + * @param transaction - Raw transaction data from the generator + * @param params - Mint parameters (receiver address, amount) + * @param metadata - Safe metadata including token address (chain ID, Safe address, owner) + * + * @returns Complete Safe Transaction Builder JSON ready for export + * + * @see {@link SafeTransactionBuilderJSON} for output format structure + * @see {@link buildSafeTransactionJson} for JSON builder utility + */ + format( + transaction: SafeTransactionDataBase, + params: MintParams, + metadata: SafeMintMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates a token minting transaction formatter. + * + * Factory function that creates a formatter for converting token minting transactions + * into Safe Transaction Builder JSON format. Works with BurnMintERC20 tokens that have + * the MINTER_ROLE permission system. + * + * @param interfaceProvider - Provider for contract ABI interfaces (BurnMintERC20) + * + * @returns Formatter instance implementing {@link MintFormatter} interface + * + * @remarks + * The formatter: + * 1. Extracts the `mint` method fragment from BurnMintERC20 interface + * 2. Builds descriptive transaction name ("Mint Tokens") + * 3. Creates human-readable description showing amount and receiver + * 4. Includes contract method signature with input types + * 5. Formats complete Safe JSON with metadata (chain ID, Safe address, owner, token address) + * 6. Returns JSON ready for Safe Transaction Builder import + * + * Minting Requirements: + * - **MINTER_ROLE**: The caller (Safe contract) must have MINTER_ROLE on the token + * - **Amount Format**: String representation in token wei (smallest unit) + * - **Recipient Validation**: Must be a valid Ethereum address + * + * Workflow Integration: + * - Used after deploying token and granting MINTER_ROLE to Safe + * - Can be used to mint initial supply or additional tokens + * - Often used for testing cross-chain transfers after pool deployment + * + * @example + * ```typescript + * const formatter = createMintFormatter(interfaceProvider); + * + * // Format mint transaction for Safe (1000 tokens with 18 decimals) + * const transaction = await generator.generate(inputJson, tokenAddress); + * const params: MintParams = { + * receiver: "0x1234567890123456789012345678901234567890", + * amount: "1000000000000000000000" // 1000 tokens (18 decimals) + * }; + * const metadata: SafeMintMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner", + * tokenAddress: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * }; + * + * const safeJson = formatter.format(transaction, params, metadata); + * + * // Save to file for Safe Transaction Builder + * fs.writeFileSync('mint.json', JSON.stringify(safeJson, null, 2)); + * + * // JSON structure includes: + * console.log(safeJson.meta.name); // "Mint Tokens" + * console.log(safeJson.meta.description); // "Mint 1000000000000000000000 tokens to 0x1234..." + * console.log(safeJson.transactions[0].to); // Token address + * console.log(safeJson.transactions[0].contractMethod.name); // "mint" + * ``` + * + * @example + * ```typescript + * // Complete workflow: Deploy token, grant role, mint + * const tokenGen = createTokenDeploymentGenerator(logger, interfaceProvider, addressComputer); + * const roleGen = createRoleManagementGenerator(logger, interfaceProvider); + * const mintGen = createTokenMintGenerator(logger, interfaceProvider); + * const mintFormatter = createMintFormatter(interfaceProvider); + * + * // Step 1: Deploy token and pool + * const deployTx = await tokenGen.generate( + * JSON.stringify({ + * name: "MyToken", + * symbol: "MTK", + * decimals: 18, + * maxSupply: "1000000000000000000000000", + * preMint: "0", + * remoteTokenPools: [] + * }), + * factoryAddress, + * salt, + * safeAddress + * ); + * // Execute deployment and get token address... + * + * // Step 2: Grant MINTER_ROLE to Safe + * const roleGrantTx = await roleGen.generate( + * JSON.stringify({ + * operation: "grant", + * roleType: "mint", + * poolOrAddress: safeAddress // Grant to Safe itself + * }), + * tokenAddress + * ); + * // Execute role grant... + * + * // Step 3: Mint tokens + * const mintTx = await mintGen.generate( + * JSON.stringify({ + * receiver: "0xRecipient", + * amount: "1000000000000000000000" + * }), + * tokenAddress + * ); + * + * // Step 4: Format for Safe + * const safeJson = mintFormatter.format(mintTx, { + * receiver: "0xRecipient", + * amount: "1000000000000000000000" + * }, { + * chainId: "84532", + * safeAddress: safeAddress, + * ownerAddress: ownerAddress, + * tokenAddress: tokenAddress + * }); + * + * // Step 5: Export and execute via Safe UI + * fs.writeFileSync('mint.json', JSON.stringify(safeJson, null, 2)); + * ``` + * + * @example + * ```typescript + * // Format small amount mint (USDC with 6 decimals) + * const transaction = await generator.generate( + * JSON.stringify({ + * receiver: "0x1234567890123456789012345678901234567890", + * amount: "1000000" // 1 USDC (6 decimals) + * }), + * usdcTokenAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * receiver: "0x1234567890123456789012345678901234567890", + * amount: "1000000" + * }, { + * chainId: "11155111", // Ethereum Sepolia + * safeAddress: safeAddress, + * ownerAddress: ownerAddress, + * tokenAddress: usdcTokenAddress + * }); + * // Description: "Mint 1000000 tokens to 0x1234..." + * ``` + * + * @example + * ```typescript + * // Mint to pool address for testing cross-chain transfers + * const poolAddress = "0x779877A7B0D9E8603169DdbD7836e478b4624789"; + * + * const transaction = await generator.generate( + * JSON.stringify({ + * receiver: poolAddress, + * amount: "10000000000000000000" // 10 tokens for testing + * }), + * tokenAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * receiver: poolAddress, + * amount: "10000000000000000000" + * }, { + * chainId: "84532", + * safeAddress: safeAddress, + * ownerAddress: ownerAddress, + * tokenAddress: tokenAddress + * }); + * // Pool now has tokens available for cross-chain transfers + * ``` + * + * @see {@link MintFormatter} for interface definition + * @see {@link buildSafeTransactionJson} for JSON builder implementation + * @see {@link SafeTransactionBuilderJSON} for complete output format specification + * @see {@link SafeMintMetadata} for metadata structure including token address + * + * @public + */ +export function createMintFormatter(interfaceProvider: IInterfaceProvider): MintFormatter { + return { + format( + transaction: SafeTransactionDataBase, + params: MintParams, + metadata: SafeMintMetadata, + ): SafeTransactionBuilderJSON { + return buildSafeTransactionJson({ + transaction, + metadata, + name: 'Mint Tokens', + description: `Mint ${params.amount} tokens to ${params.receiver}`, + contractInterface: interfaceProvider.getFactoryBurnMintERC20Interface(), + functionName: 'mint', + }); + }, + }; +} diff --git a/src/formatters/poolDeploymentFormatter.ts b/src/formatters/poolDeploymentFormatter.ts new file mode 100644 index 0000000..c4b5f5d --- /dev/null +++ b/src/formatters/poolDeploymentFormatter.ts @@ -0,0 +1,180 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for pool deployment transactions. + * + * This module formats pool deployment transaction data into Safe Transaction Builder + * JSON format for existing tokens. Supports both BurnMintTokenPool and LockReleaseTokenPool + * deployment formatting with descriptive metadata. + * + * @module formatters/poolDeploymentFormatter + */ + +import { PoolDeploymentParams } from '../types/poolDeployment'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON, SafeMetadata } from '../types/safe'; +import { IInterfaceProvider } from '../interfaces'; +import { buildSafeTransactionJson } from '../utils/safeJsonBuilder'; + +/** + * Formatter interface for pool deployment transactions to Safe JSON format. + * + * Converts raw pool deployment transaction data into the Safe Transaction Builder + * JSON format, including contract method signatures, pool type information, and + * descriptive metadata for UI display. + * + * @remarks + * The formatter extracts the `deployTokenPoolWithExistingToken` method fragment from + * the TokenPoolFactory interface and builds a complete Safe JSON structure with + * human-readable pool type descriptions. + * + * @public + */ +export interface PoolDeploymentFormatter { + /** + * Formats a pool deployment transaction to Safe Transaction Builder JSON. + * + * @param transaction - Raw transaction data from the generator + * @param params - Pool deployment parameters (token, decimals, pool type, etc.) + * @param metadata - Safe metadata (chain ID, Safe address, owner) + * + * @returns Complete Safe Transaction Builder JSON ready for export + * + * @see {@link SafeTransactionBuilderJSON} for output format structure + * @see {@link buildSafeTransactionJson} for JSON builder utility + */ + format( + transaction: SafeTransactionDataBase, + params: PoolDeploymentParams, + metadata: SafeMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates a pool deployment transaction formatter. + * + * Factory function that creates a formatter for converting pool deployment transactions + * into Safe Transaction Builder JSON format. Handles both BurnMintTokenPool and + * LockReleaseTokenPool deployments with appropriate descriptions. + * + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPoolFactory) + * + * @returns Formatter instance implementing {@link PoolDeploymentFormatter} interface + * + * @remarks + * The formatter: + * 1. Extracts the `deployTokenPoolWithExistingToken` method fragment from factory interface + * 2. Builds descriptive transaction name and description using pool type and token address + * 3. Includes contract method signature with input/output types + * 4. Formats complete Safe JSON with metadata (chain ID, Safe address, owner) + * 5. Returns JSON ready for Safe Transaction Builder import + * + * Pool Type Descriptions: + * - **BurnMintTokenPool**: For tokens with mint/burn capabilities (cross-chain mint/burn) + * - **LockReleaseTokenPool**: For tokens without mint/burn (lock-and-release mechanism) + * + * @example + * ```typescript + * const formatter = createPoolDeploymentFormatter(interfaceProvider); + * + * // Format BurnMintTokenPool deployment for Safe + * const transaction = await generator.generate(inputJson, factoryAddress, salt); + * const params: PoolDeploymentParams = { + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * decimals: 18, + * poolType: "BurnMintTokenPool", + * remoteTokenPools: [] + * }; + * const metadata: SafeMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner" + * }; + * + * const safeJson = formatter.format(transaction, params, metadata); + * + * // Save to file for Safe Transaction Builder + * fs.writeFileSync('pool-deployment.json', JSON.stringify(safeJson, null, 2)); + * + * // JSON structure includes: + * console.log(safeJson.meta.name); // "Pool Factory Deployment - BurnMintTokenPool" + * console.log(safeJson.meta.description); // "Deploy BurnMintTokenPool for token at 0x779..." + * console.log(safeJson.transactions[0].contractMethod.name); // "deployTokenPoolWithExistingToken" + * ``` + * + * @example + * ```typescript + * // Format LockReleaseTokenPool deployment + * const transaction = await generator.generate(inputJson, factoryAddress, salt); + * const params: PoolDeploymentParams = { + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * decimals: 6, // USDC decimals + * poolType: "LockReleaseTokenPool", + * remoteTokenPools: [] + * }; + * + * const safeJson = formatter.format(transaction, params, metadata); + * // Description: "Deploy LockReleaseTokenPool for token at 0x779..." + * ``` + * + * @example + * ```typescript + * // Complete workflow: Generate and format for Safe + * const generator = createPoolDeploymentGenerator(logger, interfaceProvider); + * const formatter = createPoolDeploymentFormatter(interfaceProvider); + * + * // Step 1: Generate pool deployment transaction + * const inputJson = JSON.stringify({ + * token: "0xExistingTokenAddress", + * decimals: 18, + * poolType: "BurnMintTokenPool", + * remoteTokenPools: [{ + * remoteChainSelector: "16015286601757825753", + * remotePoolAddress: "0xRemotePoolAddress", + * remoteTokenAddress: "0xRemoteTokenAddress", + * poolType: "BurnMintTokenPool" + * }] + * }); + * + * const transaction = await generator.generate( + * inputJson, + * factoryAddress, + * salt + * ); + * + * // Step 2: Format for Safe + * const params = JSON.parse(inputJson); + * const safeJson = formatter.format(transaction, params, { + * chainId: "11155111", // Ethereum Sepolia + * safeAddress: safeAddress, + * ownerAddress: ownerAddress + * }); + * + * // Step 3: Export and import into Safe UI + * fs.writeFileSync('pool-deployment.json', JSON.stringify(safeJson, null, 2)); + * // Import this file in Safe Transaction Builder web UI + * ``` + * + * @see {@link PoolDeploymentFormatter} for interface definition + * @see {@link buildSafeTransactionJson} for JSON builder implementation + * @see {@link SafeTransactionBuilderJSON} for complete output format specification + * + * @public + */ +export function createPoolDeploymentFormatter( + interfaceProvider: IInterfaceProvider, +): PoolDeploymentFormatter { + return { + format( + transaction: SafeTransactionDataBase, + params: PoolDeploymentParams, + metadata: SafeMetadata, + ): SafeTransactionBuilderJSON { + return buildSafeTransactionJson({ + transaction, + metadata, + name: `Pool Factory Deployment - ${params.poolType}`, + description: `Deploy ${params.poolType} for token at ${params.token} using factory`, + contractInterface: interfaceProvider.getTokenPoolFactoryInterface(), + functionName: 'deployTokenPoolWithExistingToken', + }); + }, + }; +} diff --git a/src/formatters/rateLimiterFormatter.ts b/src/formatters/rateLimiterFormatter.ts new file mode 100644 index 0000000..1c4bff6 --- /dev/null +++ b/src/formatters/rateLimiterFormatter.ts @@ -0,0 +1,330 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for rate limiter configuration transactions. + * + * This module formats rate limiter configuration transaction data into Safe Transaction Builder + * JSON format. Handles per-chain rate limiter updates for TokenPool contracts, providing + * user-friendly summaries of outbound and inbound rate limiter settings. + * + * @module formatters/rateLimiterFormatter + */ + +import { SetChainRateLimiterConfigInput, SafeRateLimiterMetadata } from '../types/rateLimiter'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON } from '../types/safe'; +import { IInterfaceProvider } from '../interfaces'; +import { buildSafeTransactionJson } from '../utils/safeJsonBuilder'; + +/** + * Formatter interface for rate limiter configuration transactions to Safe JSON format. + * + * Converts raw rate limiter configuration transaction data into the Safe Transaction Builder + * JSON format, including contract method signatures, rate limiter settings (capacity, rate, + * enabled status), and descriptive metadata for UI display. + * + * @remarks + * The formatter extracts the `setChainRateLimiterConfig` method fragment from the TokenPool + * interface and builds a complete Safe JSON structure with human-readable descriptions + * showing the enabled/disabled status for both outbound and inbound rate limiters. + * + * @public + */ +export interface RateLimiterFormatter { + /** + * Formats a rate limiter configuration transaction to Safe Transaction Builder JSON. + * + * @param transaction - Raw transaction data from the generator + * @param input - Rate limiter configuration input (chain selector, outbound/inbound configs) + * @param metadata - Safe metadata including token pool address (chain ID, Safe address, owner) + * + * @returns Complete Safe Transaction Builder JSON ready for export + * + * @see {@link SafeTransactionBuilderJSON} for output format structure + * @see {@link buildSafeTransactionJson} for JSON builder utility + */ + format( + transaction: SafeTransactionDataBase, + input: SetChainRateLimiterConfigInput, + metadata: SafeRateLimiterMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates a rate limiter configuration transaction formatter. + * + * Factory function that creates a formatter for converting rate limiter configuration + * transactions into Safe Transaction Builder JSON format. Handles per-chain rate limiter + * updates with token bucket algorithm parameters (capacity, rate, enabled status). + * + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPool) + * + * @returns Formatter instance implementing {@link RateLimiterFormatter} interface + * + * @remarks + * The formatter: + * 1. Extracts the `setChainRateLimiterConfig` method fragment from TokenPool interface + * 2. Builds descriptive transaction name ("Update Chain Rate Limiter Configuration") + * 3. Creates human-readable description showing: + * - Target chain selector + * - Outbound rate limiter status (enabled/disabled) + * - Inbound rate limiter status (enabled/disabled) + * 4. Includes contract method signature with input types (chain selector, configs) + * 5. Formats complete Safe JSON with metadata (chain ID, Safe address, owner, pool address) + * 6. Returns JSON ready for Safe Transaction Builder import + * + * Rate Limiter Purpose: + * - Controls maximum rate and capacity of token transfers to/from specific chains + * - Implements token bucket algorithm for rate control + * - Provides protection against exploits and ensures controlled token flow + * - Independent outbound and inbound rate limiters per chain + * + * Configuration Parameters: + * - **isEnabled**: Boolean flag to enable/disable the rate limiter + * - **capacity**: Maximum token amount in bucket (string, in token wei) + * - **rate**: Token refill rate per second (string, in token wei/sec) + * - Separate configs for outbound (tokens leaving) and inbound (tokens arriving) + * + * Common Use Cases: + * - Enable rate limiting after chain configuration + * - Adjust limits based on security requirements + * - Disable rate limiting for high-throughput scenarios + * - Implement asymmetric limits (different in/out) + * + * @example + * ```typescript + * const formatter = createRateLimiterFormatter(interfaceProvider); + * + * // Format moderate rate limiter configuration + * const transaction = await generator.generate(inputJson, poolAddress); + * const input: SetChainRateLimiterConfigInput = { + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * outboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens + * rate: "100000000000000000" // 0.1 tokens/sec + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens + * rate: "100000000000000000" // 0.1 tokens/sec + * } + * }; + * const metadata: SafeRateLimiterMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner", + * tokenPoolAddress: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * }; + * + * const safeJson = formatter.format(transaction, input, metadata); + * + * // Save to file for Safe Transaction Builder + * fs.writeFileSync('rate-limiter-config.json', JSON.stringify(safeJson, null, 2)); + * + * // JSON structure includes: + * console.log(safeJson.meta.name); // "Update Chain Rate Limiter Configuration" + * console.log(safeJson.meta.description); // "Update rate limiter config for chain 16015286601757825753: outbound enabled, inbound enabled" + * console.log(safeJson.transactions[0].to); // Pool address + * console.log(safeJson.transactions[0].contractMethod.name); // "setChainRateLimiterConfig" + * ``` + * + * @example + * ```typescript + * // Complete workflow: Add chain and configure rate limiter + * const chainUpdateGen = createChainUpdateGenerator(logger, interfaceProvider); + * const rateLimiterGen = createRateLimiterConfigGenerator(logger, interfaceProvider); + * const rateLimiterFormatter = createRateLimiterFormatter(interfaceProvider); + * + * // Step 1: Add new chain configuration + * const chainUpdateTx = await chainUpdateGen.generate( + * JSON.stringify([ + * [], // No chains to remove + * [{ // Add Ethereum Sepolia + * remoteChainSelector: "16015286601757825753", + * remoteChainType: "evm", + * remotePoolAddresses: ["0x1234567890123456789012345678901234567890"], + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * outboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" } + * }] + * ]) + * ); + * chainUpdateTx.to = poolAddress; + * // Execute chain update... + * + * // Step 2: Configure rate limiters for the new chain + * const rateLimiterTx = await rateLimiterGen.generate( + * JSON.stringify({ + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * } + * }), + * poolAddress + * ); + * + * // Step 3: Format for Safe + * const safeJson = rateLimiterFormatter.format(rateLimiterTx, { + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * } + * }, { + * chainId: "84532", + * safeAddress: safeAddress, + * ownerAddress: ownerAddress, + * tokenPoolAddress: poolAddress + * }); + * + * // Step 4: Export and execute + * fs.writeFileSync('rate-limiter-setup.json', JSON.stringify(safeJson, null, 2)); + * // Cross-chain transfers now rate-limited + * ``` + * + * @example + * ```typescript + * // Format conservative configuration (high security, low throughput) + * const transaction = await generator.generate( + * JSON.stringify({ + * remoteChainSelector: "10344971235874465080", // Base Sepolia + * outboundConfig: { + * isEnabled: true, + * capacity: "100000000000000000000", // 100 tokens max + * rate: "10000000000000000" // 0.01 tokens/sec (slow refill) + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "100000000000000000000", // 100 tokens max + * rate: "10000000000000000" // 0.01 tokens/sec (slow refill) + * } + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * remoteChainSelector: "10344971235874465080", + * outboundConfig: { isEnabled: true, capacity: "100000000000000000000", rate: "10000000000000000" }, + * inboundConfig: { isEnabled: true, capacity: "100000000000000000000", rate: "10000000000000000" } + * }, metadata); + * + * // Description: "Update rate limiter config for chain 10344971235874465080: outbound enabled, inbound enabled" + * ``` + * + * @example + * ```typescript + * // Format asymmetric configuration (strict outbound, lenient inbound) + * const transaction = await generator.generate( + * JSON.stringify({ + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: true, + * capacity: "500000000000000000000", // 500 tokens max outbound + * rate: "50000000000000000" // 0.05 tokens/sec outbound + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "5000000000000000000000", // 5000 tokens max inbound + * rate: "500000000000000000" // 0.5 tokens/sec inbound + * } + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { isEnabled: true, capacity: "500000000000000000000", rate: "50000000000000000" }, + * inboundConfig: { isEnabled: true, capacity: "5000000000000000000000", rate: "500000000000000000" } + * }, metadata); + * + * // Allows more tokens to come in than go out (useful for certain token flows) + * ``` + * + * @example + * ```typescript + * // Disable rate limiting (maximum risk, maximum throughput) + * const transaction = await generator.generate( + * JSON.stringify({ + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * inboundConfig: { isEnabled: false, capacity: "0", rate: "0" } + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * inboundConfig: { isEnabled: false, capacity: "0", rate: "0" } + * }, metadata); + * + * // Description: "Update rate limiter config for chain 16015286601757825753: outbound disabled, inbound disabled" + * // No rate limiting - all transfers allowed (use with caution) + * ``` + * + * @example + * ```typescript + * // Mixed configuration: outbound enabled, inbound disabled + * const transaction = await generator.generate( + * JSON.stringify({ + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * }, + * inboundConfig: { isEnabled: false, capacity: "0", rate: "0" } + * }), + * poolAddress + * ); + * + * const safeJson = formatter.format(transaction, { + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { isEnabled: true, capacity: "1000000000000000000000", rate: "100000000000000000" }, + * inboundConfig: { isEnabled: false, capacity: "0", rate: "0" } + * }, metadata); + * + * // Description: "Update rate limiter config for chain 16015286601757825753: outbound enabled, inbound disabled" + * // Only limit tokens going out, not coming in + * ``` + * + * @see {@link RateLimiterFormatter} for interface definition + * @see {@link buildSafeTransactionJson} for JSON builder implementation + * @see {@link SafeTransactionBuilderJSON} for complete output format specification + * @see {@link SafeRateLimiterMetadata} for metadata structure including token pool address + * + * @public + */ +export function createRateLimiterFormatter( + interfaceProvider: IInterfaceProvider, +): RateLimiterFormatter { + return { + format( + transaction: SafeTransactionDataBase, + input: SetChainRateLimiterConfigInput, + metadata: SafeRateLimiterMetadata, + ): SafeTransactionBuilderJSON { + const description = `Update rate limiter config for chain ${input.remoteChainSelector}: outbound ${input.outboundConfig.isEnabled ? 'enabled' : 'disabled'}, inbound ${input.inboundConfig.isEnabled ? 'enabled' : 'disabled'}`; + + return buildSafeTransactionJson({ + transaction, + metadata, + name: 'Update Chain Rate Limiter Configuration', + description, + contractInterface: interfaceProvider.getTokenPoolInterface(), + functionName: 'setChainRateLimiterConfig', + }); + }, + }; +} diff --git a/src/formatters/roleManagementFormatter.ts b/src/formatters/roleManagementFormatter.ts new file mode 100644 index 0000000..c7e7a97 --- /dev/null +++ b/src/formatters/roleManagementFormatter.ts @@ -0,0 +1,276 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for role management transactions. + * + * This module formats role management transaction data into Safe Transaction Builder + * JSON format. Handles granting and revoking MINTER_ROLE and BURNER_ROLE on BurnMintERC20 + * tokens, supporting single and multi-transaction operations with descriptive metadata. + * + * @module formatters/roleManagementFormatter + */ + +import { RoleManagementParams, SafeRoleManagementMetadata } from '../types/tokenMint'; +import { RoleManagementTransactionResult } from '../generators/roleManagement'; +import { SafeTransactionBuilderJSON } from '../types/safe'; +import { IInterfaceProvider } from '../interfaces'; +import { buildMultiSafeTransactionJson } from '../utils/safeJsonBuilder'; + +/** + * Formatter interface for role management transactions to Safe JSON format. + * + * Converts raw role management transaction data into the Safe Transaction Builder + * JSON format, including contract method signatures, role grant/revoke operations, + * and descriptive metadata for UI display. + * + * @remarks + * The formatter extracts role management method fragments from the BurnMintERC20 + * interface and builds a complete Safe JSON structure. Supports both single-transaction + * operations (grant mint, grant burn, revoke mint, revoke burn) and multi-transaction + * operations (revoke both = 2 separate transactions). + * + * @public + */ +export interface RoleManagementFormatter { + /** + * Formats role management transaction(s) to Safe Transaction Builder JSON. + * + * @param result - Transaction result from generator (may contain 1 or 2 transactions) + * @param params - Role management parameters (action, roleType, pool address) + * @param metadata - Safe metadata including token address (chain ID, Safe address, owner) + * + * @returns Complete Safe Transaction Builder JSON ready for export + * + * @see {@link SafeTransactionBuilderJSON} for output format structure + * @see {@link buildMultiSafeTransactionJson} for multi-transaction JSON builder utility + */ + format( + result: RoleManagementTransactionResult, + params: RoleManagementParams, + metadata: SafeRoleManagementMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates a role management transaction formatter. + * + * Factory function that creates a formatter for converting role management transactions + * into Safe Transaction Builder JSON format. Handles both grant and revoke operations + * for MINTER_ROLE and BURNER_ROLE on BurnMintERC20 tokens. + * + * @param interfaceProvider - Provider for contract ABI interfaces (BurnMintERC20) + * + * @returns Formatter instance implementing {@link RoleManagementFormatter} interface + * + * @remarks + * The formatter: + * 1. Extracts role management method fragments from BurnMintERC20 interface + * 2. Builds descriptive transaction name based on action (Grant/Revoke) + * 3. Creates human-readable description showing role type and target pool + * 4. Includes contract method signature(s) with input types + * 5. Formats complete Safe JSON with metadata (chain ID, Safe address, owner, token address) + * 6. Supports multiple transactions in single JSON (for revoke both) + * 7. Returns JSON ready for Safe Transaction Builder import + * + * Transaction Count by Operation: + * - **Grant mint**: 1 transaction (grantMintAndBurn with isMinter=true, isBurner=false) + * - **Grant burn**: 1 transaction (grantMintAndBurn with isMinter=false, isBurner=true) + * - **Grant both**: 1 transaction (grantMintAndBurn with both true) + * - **Revoke mint**: 1 transaction (revokeMintRole) + * - **Revoke burn**: 1 transaction (revokeBurnRole) + * - **Revoke both**: 2 transactions (revokeMintRole + revokeBurnRole) + * + * Role Requirements: + * - Caller must have DEFAULT_ADMIN_ROLE on the token contract + * - Safe contract typically has admin role after token deployment + * - Pools must have mint/burn roles before cross-chain transfers work + * + * Common Workflow: + * - Deploy token and pool via TokenPoolFactory + * - Grant both mint and burn roles to the pool + * - Pool can now mint/burn tokens during cross-chain transfers + * + * @example + * ```typescript + * const formatter = createRoleManagementFormatter(interfaceProvider); + * + * // Format role grant for pool (most common use case) + * const result = await generator.generate(inputJson, tokenAddress); + * const params: RoleManagementParams = { + * action: "grant", + * roleType: "both", + * pool: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * }; + * const metadata: SafeRoleManagementMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner", + * tokenAddress: "0xTokenAddress" + * }; + * + * const safeJson = formatter.format(result, params, metadata); + * + * // Save to file for Safe Transaction Builder + * fs.writeFileSync('grant-roles.json', JSON.stringify(safeJson, null, 2)); + * + * // JSON structure includes: + * console.log(safeJson.meta.name); // "Grant Token Pool Roles" + * console.log(safeJson.meta.description); // "Grant mint and burn roles to pool 0x779..." + * console.log(safeJson.transactions.length); // 1 (single transaction for grant both) + * console.log(safeJson.transactions[0].contractMethod.name); // "grantMintAndBurn" + * ``` + * + * @example + * ```typescript + * // Complete workflow: Deploy pool and grant roles + * const poolGen = createPoolDeploymentGenerator(logger, interfaceProvider); + * const roleGen = createRoleManagementGenerator(logger, interfaceProvider); + * const roleFormatter = createRoleManagementFormatter(interfaceProvider); + * + * // Step 1: Deploy pool for existing token + * const poolTx = await poolGen.generate( + * JSON.stringify({ + * token: "0xExistingTokenAddress", + * decimals: 18, + * poolType: "BurnMintTokenPool", + * remoteTokenPools: [] + * }), + * factoryAddress, + * salt + * ); + * // Execute deployment and get pool address... + * + * // Step 2: Grant both mint and burn roles to pool + * const roleResult = await roleGen.generate( + * JSON.stringify({ + * action: "grant", + * roleType: "both", + * pool: deployedPoolAddress + * }), + * tokenAddress + * ); + * + * // Step 3: Format for Safe + * const safeJson = roleFormatter.format(roleResult, { + * action: "grant", + * roleType: "both", + * pool: deployedPoolAddress + * }, { + * chainId: "84532", + * safeAddress: safeAddress, + * ownerAddress: ownerAddress, + * tokenAddress: tokenAddress + * }); + * + * // Step 4: Export and execute via Safe UI + * fs.writeFileSync('grant-roles.json', JSON.stringify(safeJson, null, 2)); + * // Pool can now mint/burn during cross-chain transfers + * ``` + * + * @example + * ```typescript + * // Grant only mint role (uncommon, but supported) + * const result = await generator.generate( + * JSON.stringify({ + * action: "grant", + * roleType: "mint", + * pool: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * }), + * tokenAddress + * ); + * + * const safeJson = formatter.format(result, { + * action: "grant", + * roleType: "mint", + * pool: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * }, metadata); + * + * // Description: "Grant mint role to pool 0x779..." + * // Transactions: 1 (grantMintAndBurn with isMinter=true, isBurner=false) + * ``` + * + * @example + * ```typescript + * // Revoke both roles (generates 2 transactions) + * const result = await generator.generate( + * JSON.stringify({ + * action: "revoke", + * roleType: "both", + * pool: "0xDeprecatedPool" + * }), + * tokenAddress + * ); + * + * const safeJson = formatter.format(result, { + * action: "revoke", + * roleType: "both", + * pool: "0xDeprecatedPool" + * }, metadata); + * + * // Description: "Revoke mint and burn roles from pool 0xDep..." + * // Transactions: 2 (revokeMintRole + revokeBurnRole) + * console.log(safeJson.transactions.length); // 2 + * console.log(safeJson.transactions[0].contractMethod.name); // "revokeMintRole" + * console.log(safeJson.transactions[1].contractMethod.name); // "revokeBurnRole" + * ``` + * + * @example + * ```typescript + * // Revoke single role (1 transaction) + * const result = await generator.generate( + * JSON.stringify({ + * action: "revoke", + * roleType: "burn", + * pool: "0xPoolAddress" + * }), + * tokenAddress + * ); + * + * const safeJson = formatter.format(result, { + * action: "revoke", + * roleType: "burn", + * pool: "0xPoolAddress" + * }, metadata); + * + * // Description: "Revoke burn role from pool 0xPool..." + * // Transactions: 1 (revokeBurnRole) + * ``` + * + * @see {@link RoleManagementFormatter} for interface definition + * @see {@link buildMultiSafeTransactionJson} for multi-transaction JSON builder + * @see {@link SafeTransactionBuilderJSON} for complete output format specification + * @see {@link SafeRoleManagementMetadata} for metadata structure including token address + * + * @public + */ +export function createRoleManagementFormatter( + interfaceProvider: IInterfaceProvider, +): RoleManagementFormatter { + return { + format( + result: RoleManagementTransactionResult, + params: RoleManagementParams, + metadata: SafeRoleManagementMetadata, + ): SafeTransactionBuilderJSON { + // Build description + const action = params.action === 'grant' ? 'Grant' : 'Revoke'; + let roleDescription: string; + if (params.roleType === 'mint') { + roleDescription = 'mint role'; + } else if (params.roleType === 'burn') { + roleDescription = 'burn role'; + } else { + roleDescription = 'mint and burn roles'; + } + + const description = `${action} ${roleDescription} ${params.action === 'grant' ? 'to' : 'from'} pool ${params.pool}`; + + return buildMultiSafeTransactionJson({ + transactions: result.transactions, + metadata, + name: `${action} Token Pool Roles`, + description, + contractInterface: interfaceProvider.getFactoryBurnMintERC20Interface(), + functionNames: result.functionNames, + }); + }, + }; +} diff --git a/src/formatters/tokenDeploymentFormatter.ts b/src/formatters/tokenDeploymentFormatter.ts new file mode 100644 index 0000000..a1478ea --- /dev/null +++ b/src/formatters/tokenDeploymentFormatter.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Safe Transaction Builder JSON formatter for token deployment transactions. + * + * This module formats token deployment transaction data into Safe Transaction Builder + * JSON format, enabling import into the Safe web UI or CLI. Extracts method signatures + * and formats transaction metadata for user-friendly display. + * + * @module formatters/tokenDeploymentFormatter + */ + +import { TokenDeploymentParams } from '../types/tokenDeployment'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON, SafeMetadata } from '../types/safe'; +import { IInterfaceProvider } from '../interfaces'; +import { buildSafeTransactionJson } from '../utils/safeJsonBuilder'; + +/** + * Formatter interface for token deployment transactions to Safe JSON format. + * + * Converts raw token deployment transaction data into the Safe Transaction Builder + * JSON format, including contract method signatures, input types, and descriptive + * metadata for UI display. + * + * @remarks + * The formatter extracts the `deployTokenAndTokenPool` method fragment from the + * TokenPoolFactory interface and builds a complete Safe JSON structure with + * human-readable transaction descriptions. + * + * @public + */ +export interface TokenDeploymentFormatter { + /** + * Formats a token deployment transaction to Safe Transaction Builder JSON. + * + * @param transaction - Raw transaction data from the generator + * @param params - Token deployment parameters (name, symbol, decimals, etc.) + * @param metadata - Safe metadata (chain ID, Safe address, owner) + * + * @returns Complete Safe Transaction Builder JSON ready for export + * + * @see {@link SafeTransactionBuilderJSON} for output format structure + * @see {@link buildSafeTransactionJson} for JSON builder utility + */ + format( + transaction: SafeTransactionDataBase, + params: TokenDeploymentParams, + metadata: SafeMetadata, + ): SafeTransactionBuilderJSON; +} + +/** + * Creates a token deployment transaction formatter. + * + * Factory function that creates a formatter for converting token deployment transactions + * into Safe Transaction Builder JSON format. The output can be directly imported into + * the Safe web UI for execution. + * + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPoolFactory) + * + * @returns Formatter instance implementing {@link TokenDeploymentFormatter} interface + * + * @remarks + * The formatter: + * 1. Extracts the `deployTokenAndTokenPool` method fragment from factory interface + * 2. Builds descriptive transaction name and description using token parameters + * 3. Includes contract method signature with input/output types + * 4. Formats complete Safe JSON with metadata (chain ID, Safe address, owner) + * 5. Returns JSON ready for Safe Transaction Builder import + * + * Safe Transaction Builder Format: + * - **version**: Safe TX Builder format version (currently "1.0") + * - **chainId**: Target blockchain chain ID + * - **meta**: Transaction metadata (name, description, creator info) + * - **transactions**: Array of transaction objects with method details + * + * Output Usage: + * - Save JSON to file + * - Import into Safe web UI via Transaction Builder + * - Review transaction details in user-friendly format + * - Execute via Safe multisig workflow + * + * @example + * ```typescript + * const formatter = createTokenDeploymentFormatter(interfaceProvider); + * + * // Format generator output for Safe + * const transaction = await generator.generate(inputJson, factoryAddress, salt, safeAddress); + * const params: TokenDeploymentParams = { + * name: "MyToken", + * symbol: "MTK", + * decimals: 18, + * maxSupply: "1000000000000000000000000", + * preMint: "100000000000000000000", + * remoteTokenPools: [] + * }; + * const metadata: SafeMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner" + * }; + * + * const safeJson = formatter.format(transaction, params, metadata); + * + * // Save to file for Safe Transaction Builder + * fs.writeFileSync('token-deployment.json', JSON.stringify(safeJson, null, 2)); + * + * // JSON structure includes: + * console.log(safeJson.version); // "1.0" + * console.log(safeJson.chainId); // "84532" + * console.log(safeJson.meta.name); // "Token and Pool Factory Deployment - MyToken" + * console.log(safeJson.transactions[0].to); // Factory address + * console.log(safeJson.transactions[0].contractMethod.name); // "deployTokenAndTokenPool" + * ``` + * + * @example + * ```typescript + * // Complete workflow: Generate and format for Safe + * const generator = createTokenDeploymentGenerator(logger, interfaceProvider, addressComputer); + * const formatter = createTokenDeploymentFormatter(interfaceProvider); + * + * // Step 1: Generate transaction + * const inputJson = JSON.stringify({ + * name: "CrossChainToken", + * symbol: "CCT", + * decimals: 18, + * maxSupply: "1000000000000000000000000", + * preMint: "0", + * remoteTokenPools: [] + * }); + * + * const transaction = await generator.generate( + * inputJson, + * factoryAddress, + * salt, + * safeAddress + * ); + * + * // Step 2: Format for Safe + * const params = JSON.parse(inputJson); + * const safeJson = formatter.format(transaction, params, { + * chainId: "11155111", // Ethereum Sepolia + * safeAddress: safeAddress, + * ownerAddress: ownerAddress + * }); + * + * // Step 3: Export and import into Safe UI + * fs.writeFileSync('deployment.json', JSON.stringify(safeJson, null, 2)); + * // Import this file in Safe Transaction Builder web UI + * ``` + * + * @see {@link TokenDeploymentFormatter} for interface definition + * @see {@link buildSafeTransactionJson} for JSON builder implementation + * @see {@link SafeTransactionBuilderJSON} for complete output format specification + * + * @public + */ +export function createTokenDeploymentFormatter( + interfaceProvider: IInterfaceProvider, +): TokenDeploymentFormatter { + return { + format( + transaction: SafeTransactionDataBase, + params: TokenDeploymentParams, + metadata: SafeMetadata, + ): SafeTransactionBuilderJSON { + return buildSafeTransactionJson({ + transaction, + metadata, + name: `Token and Pool Factory Deployment - ${params.name}`, + description: `Deploy ${params.name} (${params.symbol}) token and associated pool using factory`, + contractInterface: interfaceProvider.getTokenPoolFactoryInterface(), + functionName: 'deployTokenAndTokenPool', + }); + }, + }; +} diff --git a/src/generators/acceptOwnership.ts b/src/generators/acceptOwnership.ts new file mode 100644 index 0000000..7ed785d --- /dev/null +++ b/src/generators/acceptOwnership.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Accept ownership transaction generator for contracts with two-step ownership. + * + * This module generates transactions for accepting ownership of contracts that use the + * two-step ownership transfer pattern (transferOwnership + acceptOwnership). This pattern + * is used by Chainlink contracts including BurnMintERC20 tokens and TokenPool contracts. + * + * Two-Step Ownership Pattern: + * 1. Current owner calls `transferOwnership(newOwner)` - sets `pendingOwner` + * 2. New owner calls `acceptOwnership()` - becomes the actual `owner` + * + * @module generators/acceptOwnership + */ + +import { ethers } from 'ethers'; + +import { SafeOperationType, SafeTransactionDataBase } from '../types/safe'; +import { DEFAULTS } from '../config'; +import { AcceptOwnershipError } from '../errors'; +import { executeAsync } from '../errors/AsyncErrorHandler'; +import { ILogger } from '../interfaces'; + +/** + * Generator interface for accept ownership transactions. + * + * Generates transactions for the `acceptOwnership()` function call. This function + * has no parameters - it simply transfers ownership from the current owner to + * the caller (who must be the `pendingOwner`). + * + * @remarks + * Use Cases: + * - After TokenPoolFactory deployment, Safe is set as pendingOwner on token and pool + * - Safe must accept ownership before calling owner-only functions like `applyChainUpdates` + * - Any contract using Chainlink's two-step ownership pattern + * + * @example + * ```typescript + * const generator = createAcceptOwnershipGenerator(logger); + * const tx = generator.generate("0xPoolAddress..."); + * // tx.data = "0x79ba5097" (acceptOwnership function selector) + * ``` + * + * @public + */ +export interface AcceptOwnershipGenerator { + /** + * Generates accept ownership transaction data. + * + * @param contractAddress - Address of the contract to accept ownership of + * @returns Promise resolving to transaction data for Safe execution + */ + generate(contractAddress: string): Promise; +} + +/** + * Creates an accept ownership transaction generator. + * + * Factory function that creates a generator for `acceptOwnership()` transactions. + * The generated transactions can be used with any contract that implements the + * two-step ownership transfer pattern. + * + * @param logger - Logger instance for operation logging + * @returns Accept ownership generator instance + * + * @remarks + * The `acceptOwnership()` function: + * - Has no parameters + * - Must be called by the `pendingOwner` address + * - Transfers ownership from current owner to caller + * - Clears the `pendingOwner` field + * + * Function Signature: + * ```solidity + * function acceptOwnership() external + * ``` + * + * Function Selector: `0x79ba5097` + * + * @example + * ```typescript + * const logger = createLogger(); + * const generator = createAcceptOwnershipGenerator(logger); + * + * // Generate for a token + * const tokenTx = generator.generate("0xTokenAddress..."); + * + * // Generate for a pool + * const poolTx = generator.generate("0xPoolAddress..."); + * ``` + * + * @public + */ +export function createAcceptOwnershipGenerator(logger: ILogger): AcceptOwnershipGenerator { + return { + async generate(contractAddress: string): Promise { + // Validate address format using executeAsync for consistency with other generators + await executeAsync( + () => { + if (!ethers.isAddress(contractAddress)) { + throw new AcceptOwnershipError('Invalid contract address format', { + contractAddress, + }); + } + return Promise.resolve(); + }, + AcceptOwnershipError, + 'Address validation failed', + { contractAddress }, + ); + + logger.info('Generating accept ownership transaction', { + contractAddress, + }); + + // acceptOwnership() has no parameters - just encode the function selector + const iface = new ethers.Interface(['function acceptOwnership()']); + const data = iface.encodeFunctionData('acceptOwnership', []); + + logger.info('Successfully generated accept ownership transaction', { + contractAddress, + functionSelector: data.slice(0, 10), + }); + + return { + to: contractAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }; + }, + }; +} diff --git a/src/generators/allowListUpdates.ts b/src/generators/allowListUpdates.ts new file mode 100644 index 0000000..a4d7889 --- /dev/null +++ b/src/generators/allowListUpdates.ts @@ -0,0 +1,244 @@ +/** + * @fileoverview Allow list management transaction generator for token pools. + * + * This module generates transactions for managing the allow list of token pool contracts, + * enabling control over which addresses are permitted to interact with the pool for + * cross-chain token transfers. Supports both adding and removing addresses atomically. + * + * @module generators/allowListUpdates + */ + +import { ethers } from 'ethers'; +import { allowListUpdatesSchema } from '../types/allowList'; +import { SafeTransactionDataBase, SafeOperationType } from '../types/safe'; +import { DEFAULTS } from '../config'; +import { AllowListUpdatesError } from '../errors'; +import { executeAsync, logError } from '../errors/AsyncErrorHandler'; +import { ILogger, IInterfaceProvider } from '../interfaces'; + +/** + * Generator interface for token pool allow list update transactions. + * + * Generates transactions for updating the allow list on token pool contracts. + * The allow list controls which addresses are permitted to initiate cross-chain + * token transfers through the pool. + * + * @remarks + * The generator validates input parameters, encodes the `applyAllowListUpdates` + * function call with addresses to add and remove, and returns transaction data + * ready for execution. + * + * @public + */ +export interface AllowListUpdatesGenerator { + /** + * Generates an allow list update transaction for a token pool. + * + * @param inputJson - JSON string containing addresses to add and remove from allow list + * @param tokenPoolAddress - Address of the TokenPool contract + * + * @returns Transaction data containing target address, encoded function call, and operation type + * + * @throws {AllowListUpdatesError} When validation fails or transaction generation fails + * + * @see {@link allowListUpdatesSchema} for input JSON schema + * @see {@link SafeTransactionDataBase} for return type structure + */ + generate(inputJson: string, tokenPoolAddress: string): Promise; +} + +/** + * Creates an allow list management transaction generator. + * + * Factory function that creates a generator for updating token pool allow lists. + * The allow list is an access control mechanism that restricts which addresses + * can initiate cross-chain token transfers through the pool. + * + * @param logger - Logger instance for operation logging and debugging + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPool) + * + * @returns Generator instance implementing {@link AllowListUpdatesGenerator} interface + * + * @remarks + * The generator follows this process: + * 1. Validates token pool address format + * 2. Validates input JSON against Zod schema (removes array, adds array) + * 3. Encodes TokenPool `applyAllowListUpdates` function call + * 4. Returns transaction data ready for execution + * + * Allow List Purpose: + * - Controls which addresses can call pool functions that initiate transfers + * - Provides an additional security layer beyond ownership/role controls + * - Useful for restricting pool access to specific integrations or contracts + * - Can be disabled by setting allow list to empty (allows all addresses) + * + * Operation Behavior: + * - **Atomic**: All additions and removals are processed in a single transaction + * - **Order**: Removals are processed before additions + * - **Idempotent**: Removing a non-existent address or adding an existing address is safe + * - **Empty Arrays**: Either array can be empty (remove-only, add-only, or both operations) + * + * @example + * ```typescript + * const generator = createAllowListUpdatesGenerator(logger, interfaceProvider); + * + * // Add addresses to allow list (no removals) + * const inputJson = JSON.stringify({ + * removes: [], + * adds: [ + * "0x1234567890123456789012345678901234567890", + * "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + * ] + * }); + * + * const transaction = await generator.generate( + * inputJson, + * "0x779877A7B0D9E8603169DdbD7836e478b4624789" // Pool address + * ); + * + * console.log(transaction.to); // Pool contract address + * console.log(transaction.data); // Encoded applyAllowListUpdates call + * console.log(transaction.value); // "0" (no ETH sent) + * console.log(transaction.operation); // SafeOperationType.Call + * + * // Execute via Safe multisig + * ``` + * + * @example + * ```typescript + * // Remove addresses from allow list (no additions) + * const inputJson = JSON.stringify({ + * removes: [ + * "0xOldAddress1", + * "0xOldAddress2" + * ], + * adds: [] + * }); + * + * const transaction = await generator.generate(inputJson, poolAddress); + * ``` + * + * @example + * ```typescript + * // Atomic update: remove old addresses and add new ones + * const inputJson = JSON.stringify({ + * removes: [ + * "0xDeprecatedIntegration" + * ], + * adds: [ + * "0xNewIntegrationV2", + * "0xAnotherAuthorizedContract" + * ] + * }); + * + * const transaction = await generator.generate(inputJson, poolAddress); + * // Removals processed first, then additions (atomic) + * ``` + * + * @example + * ```typescript + * // Disable allow list by clearing all addresses + * // Step 1: Get current allow list addresses from contract + * const currentAllowList = ["0xAddr1", "0xAddr2", "0xAddr3"]; + * + * // Step 2: Remove all addresses to disable allow list + * const inputJson = JSON.stringify({ + * removes: currentAllowList, + * adds: [] + * }); + * + * const transaction = await generator.generate(inputJson, poolAddress); + * // Empty allow list = unrestricted access (all addresses allowed) + * ``` + * + * @example + * ```typescript + * // Common workflow: Configure pool with allow list + * // Step 1: Deploy pool + * const poolGenerator = createPoolDeploymentGenerator(logger, interfaceProvider); + * const poolTx = await poolGenerator.generate(poolInputJson, factoryAddress, salt); + * // Execute and get deployed pool address... + * + * // Step 2: Set initial allow list + * const allowListGenerator = createAllowListUpdatesGenerator(logger, interfaceProvider); + * const allowListTx = await allowListGenerator.generate( + * JSON.stringify({ + * removes: [], + * adds: [ + * "0xTrustedIntegration1", + * "0xTrustedIntegration2" + * ] + * }), + * deployedPoolAddress + * ); + * // Execute allow list transaction... + * + * // Step 3: Only allowed addresses can now use the pool + * ``` + * + * @throws {AllowListUpdatesError} When token pool address is invalid + * @throws {AllowListUpdatesError} When input JSON validation fails + * @throws {AllowListUpdatesError} When addresses in removes/adds arrays are invalid + * @throws {AllowListUpdatesError} When transaction encoding fails + * + * @see {@link AllowListUpdatesGenerator} for interface definition + * @see {@link allowListUpdatesSchema} for input validation schema + * + * @public + */ +export function createAllowListUpdatesGenerator( + logger: ILogger, + interfaceProvider: IInterfaceProvider, +): AllowListUpdatesGenerator { + return { + async generate(inputJson: string, tokenPoolAddress: string): Promise { + if (!ethers.isAddress(tokenPoolAddress)) { + throw new AllowListUpdatesError('Invalid token pool address'); + } + + // Parse and validate input + const parsedInput = await executeAsync( + async () => allowListUpdatesSchema.parseAsync(JSON.parse(inputJson)), + AllowListUpdatesError, + 'Invalid input format', + { inputJson }, + ); + + logger.info('Successfully validated allow list updates input', { + removes: parsedInput.removes, + adds: parsedInput.adds, + }); + + try { + const poolInterface = interfaceProvider.getTokenPoolInterface(); + + const data = poolInterface.encodeFunctionData('applyAllowListUpdates', [ + parsedInput.removes, + parsedInput.adds, + ]); + + logger.info('Successfully generated allow list updates transaction', { + tokenPoolAddress, + removes: parsedInput.removes, + adds: parsedInput.adds, + }); + + return { + to: tokenPoolAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }; + } catch (error) { + logError(error, 'generate allow list updates transaction', { tokenPoolAddress }); + throw error instanceof Error + ? new AllowListUpdatesError( + 'Failed to generate allow list updates transaction', + undefined, + error, + ) + : error; + } + }, + }; +} diff --git a/src/generators/chainUpdateCalldata.ts b/src/generators/chainUpdateCalldata.ts index 3ca455d..deb4418 100644 --- a/src/generators/chainUpdateCalldata.ts +++ b/src/generators/chainUpdateCalldata.ts @@ -1,33 +1,66 @@ +/** + * @fileoverview Cross-chain configuration transaction generator for token pools. + * + * This module generates transactions for updating token pool cross-chain configurations, + * including adding and removing remote chains. Supports multi-chain address encoding for + * EVM (Ethereum Virtual Machine), SVM (Solana Virtual Machine), and MVM (Move Virtual Machine, + * not yet implemented). + * + * @module generators/chainUpdateCalldata + */ + import { ethers } from 'ethers'; -import { TokenPool__factory, TokenPool } from '../typechain'; +import { TokenPool } from '../typechain'; import { PublicKey } from '@solana/web3.js'; -import { - ChainType, - ChainUpdateInput, - ChainUpdatesInput, - chainUpdatesInputSchema, - SafeChainUpdateMetadata, -} from '../types/chainUpdate'; -import { - SafeTransactionDataBase, - SafeTransactionBuilderJSON, - SAFE_TX_BUILDER_VERSION, - SafeOperationType, -} from '../types/safe'; -import logger from '../utils/logger'; - -export class ChainUpdateError extends Error { - constructor(message: string) { - super(message); - this.name = 'ChainUpdateError'; - } -} +import { ChainType, ChainUpdateInput, chainUpdatesInputSchema } from '../types/chainUpdate'; +import { SafeTransactionDataBase, SafeOperationType } from '../types/safe'; +import { DEFAULTS } from '../config'; +import { ChainUpdateError } from '../errors'; +import { executeAsync, logError } from '../errors/AsyncErrorHandler'; +import { ILogger, IInterfaceProvider } from '../interfaces'; +/** + * Encoder interface for different blockchain address formats. + * + * Different blockchain architectures use different address formats that must be + * properly encoded for EVM contract calls: + * - EVM chains: 20-byte addresses (e.g., `0x779877A7B0D9E8603169DdbD7836e478b4624789`) + * - SVM chains: 32-byte Solana public keys in base58 format (e.g., `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`) + * + * @internal + */ type ChainEncoder = { + /** + * Encodes an array of pool addresses for the specific chain type. + * + * @param coder - Ethers ABI coder instance + * @param addresses - Array of addresses in chain-specific format + * @returns Array of ABI-encoded address strings + */ encodeAddresses: (coder: ethers.AbiCoder, addresses: string[]) => string[]; + + /** + * Encodes a token address for the specific chain type. + * + * @param coder - Ethers ABI coder instance + * @param address - Token address in chain-specific format + * @returns ABI-encoded address string + */ encodeToken: (coder: ethers.AbiCoder, address: string) => string; }; +/** + * Chain-specific address encoders for supported blockchain types. + * + * Each encoder handles the address format conversion for its respective chain type: + * - **EVM**: Direct encoding of 20-byte Ethereum addresses as `address` type + * - **SVM**: Conversion of base58 Solana public keys to 32-byte `bytes32` type + * + * @remarks + * MVM (Move Virtual Machine) support is planned but not yet implemented. + * + * @internal + */ const chainEncoders: Record = { [ChainType.EVM]: { encodeAddresses: (coder, addresses) => @@ -48,7 +81,78 @@ const chainEncoders: Record = { }; /** - * Converts a validated chain update input to the contract-ready format. Supports EVM as source chain, and select non EVM remote chains. + * Converts a validated chain update input to contract-ready format with proper address encoding. + * + * This function handles multi-chain address encoding, converting chain-specific address formats + * (EVM 20-byte addresses, SVM 32-byte Solana public keys) into the ABI-encoded format required + * by the TokenPool contract. + * + * @param chainUpdate - Validated chain update configuration including remote chain details + * + * @returns Contract-ready chain update struct with encoded addresses + * + * @throws {ChainUpdateError} When an unsupported chain type is provided + * @throws {ChainUpdateError} When MVM chain type is specified (not yet implemented) + * @throws {ChainUpdateError} When address encoding fails + * + * @remarks + * Supported Chain Types: + * - **EVM (Ethereum Virtual Machine)**: Encodes 20-byte addresses directly as `address` type + * - **SVM (Solana Virtual Machine)**: Converts base58 public keys to 32-byte `bytes32` type + * - **MVM (Move Virtual Machine)**: Planned but not yet implemented + * + * Address Encoding Process: + * 1. Validates chain type is supported (EVM or SVM) + * 2. Selects appropriate encoder for the chain type + * 3. Encodes pool addresses array + * 4. Encodes token address + * 5. Returns struct with encoded addresses and rate limiter configs + * + * @example + * ```typescript + * // EVM chain configuration + * const evmChainUpdate: ChainUpdateInput = { + * remoteChainSelector: "16015286601757825753", + * remoteChainType: ChainType.EVM, + * remotePoolAddresses: ["0x1234567890123456789012345678901234567890"], + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * outboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000", + * rate: "100000" + * }, + * inboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000", + * rate: "100000" + * } + * }; + * + * const contractFormat = convertToContractFormat(evmChainUpdate); + * // Result has ABI-encoded address fields + * ``` + * + * @example + * ```typescript + * // SVM (Solana) chain configuration + * const svmChainUpdate: ChainUpdateInput = { + * remoteChainSelector: "13204309965474693391", + * remoteChainType: ChainType.SVM, + * remotePoolAddresses: ["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"], + * remoteTokenAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + * outboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" } + * }; + * + * const contractFormat = convertToContractFormat(svmChainUpdate); + * // Solana public keys converted to bytes32 + * ``` + * + * @see {@link ChainUpdateInput} for input structure + * @see {@link TokenPool.ChainUpdateStruct} for return type + * @see {@link chainEncoders} for chain-specific encoding logic + * + * @public */ export function convertToContractFormat( chainUpdate: ChainUpdateInput, @@ -86,117 +190,217 @@ export function convertToContractFormat( inboundRateLimiterConfig: chainUpdate.inboundRateLimiterConfig, }; } catch (error) { - if (error instanceof Error) { - logger.error( - `Error converting remote ${chainUpdate.remoteChainType} chain update to contract format`, - { + logError(error, `convert ${chainUpdate.remoteChainType} chain update to contract format`, { + chainUpdate, + }); + throw error instanceof Error + ? new ChainUpdateError( + `Failed to convert remote ${chainUpdate.remoteChainType} chain update to contract format`, + { chainUpdate }, error, - chainUpdate, - }, - ); - throw new ChainUpdateError( - `Failed to convert remote ${chainUpdate.remoteChainType} chain update to contract format: ${error.message}`, - ); - } - throw error; + ) + : error; } } /** - * Generates a transaction for applying chain updates - * @param inputJson - The input JSON string containing chain updates. Supports EVM as source chain, and select non EVM remote chains. - * @returns The Safe transaction data + * Generator interface for token pool cross-chain configuration transactions. + * + * Generates transactions for updating token pool cross-chain configurations, + * supporting both adding new remote chains and removing existing ones. Handles + * multi-chain address encoding (EVM, SVM) and rate limiter configurations. + * + * @remarks + * The generator processes arrays of chain selectors to remove and chain configurations + * to add, encoding them into a single `applyChainUpdates` transaction. This allows + * atomic updates of multiple chain configurations in one transaction. + * + * @public */ -export async function generateChainUpdateTransaction( - inputJson: string, -): Promise { - let parsedInput: ChainUpdatesInput; - - try { - // Parse and validate the input JSON - parsedInput = await chainUpdatesInputSchema.parseAsync(JSON.parse(inputJson)); - - logger.info('Successfully validated chain updates input', { - chainsToRemoveCount: parsedInput[0].length, - chainsToAddCount: parsedInput[1].length, - }); - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to parse or validate input JSON', { error, inputJson }); - throw new ChainUpdateError(`Invalid input format: ${error.message}`); - } - throw error; - } +export interface ChainUpdateGenerator { + /** + * Generates a chain configuration update transaction. + * + * @param inputJson - JSON string containing arrays of chains to remove and add + * + * @returns Transaction data with encoded `applyChainUpdates` call (target address left empty for caller) + * + * @throws {ChainUpdateError} When validation fails or transaction generation fails + * + * @see {@link chainUpdatesInputSchema} for input JSON schema + * @see {@link SafeTransactionDataBase} for return type structure + */ + generate(inputJson: string): Promise; +} - try { - // Convert the validated input to contract format - const [chainSelectorsToRemove, chainsToAdd] = parsedInput; +/** + * Creates a cross-chain configuration update generator. + * + * Factory function that creates a generator for updating token pool cross-chain configurations. + * Supports adding new remote chains (with full configuration) and removing existing chains + * (by chain selector). Handles multi-chain address encoding for EVM and SVM chains. + * + * @param logger - Logger instance for operation logging and debugging + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPool) + * + * @returns Generator instance implementing {@link ChainUpdateGenerator} interface + * + * @remarks + * The generator follows this process: + * 1. Validates input JSON against Zod schema (tuple of [chainSelectorsToRemove, chainsToAdd]) + * 2. Converts chain configurations to contract format using appropriate encoders + * 3. Encodes TokenPool `applyChainUpdates` function call + * 4. Returns transaction data (target address must be set by caller to pool address) + * + * Input Structure: + * - **First array**: Chain selectors (uint64 as strings) of chains to remove + * - **Second array**: Full chain configurations to add with: + * - Remote chain selector + * - Chain type (EVM, SVM, or MVM) + * - Remote pool addresses + * - Remote token address + * - Inbound and outbound rate limiter configs + * + * Multi-Chain Support: + * - **EVM Chains**: Standard 20-byte Ethereum addresses (e.g., Ethereum, Polygon, Arbitrum, Base) + * - **SVM Chains**: Solana 32-byte public keys in base58 format + * - **MVM Chains**: Not yet implemented (will throw error) + * + * @example + * ```typescript + * const generator = createChainUpdateGenerator(logger, interfaceProvider); + * + * // Add Ethereum Sepolia configuration (no removals) + * const inputJson = JSON.stringify([ + * [], // No chains to remove + * [ // Chains to add + * { + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * remoteChainType: "evm", + * remotePoolAddresses: ["0x1234567890123456789012345678901234567890"], + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * outboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * }, + * inboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * } + * } + * ] + * ]); + * + * const transaction = await generator.generate(inputJson); + * // Must set transaction.to = poolAddress before execution + * transaction.to = "0x779877A7B0D9E8603169DdbD7836e478b4624789"; + * ``` + * + * @example + * ```typescript + * // Add Solana devnet configuration (SVM chain) + * const inputJson = JSON.stringify([ + * [], + * [{ + * remoteChainSelector: "13204309965474693391", // Solana devnet + * remoteChainType: "svm", + * remotePoolAddresses: ["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"], + * remoteTokenAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + * outboundRateLimiterConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * }, + * inboundRateLimiterConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * } + * }] + * ]); + * + * const transaction = await generator.generate(inputJson); + * transaction.to = poolAddress; + * ``` + * + * @example + * ```typescript + * // Remove old chain and add new configuration + * const inputJson = JSON.stringify([ + * ["16015286601757825753"], // Remove Ethereum Sepolia + * [{ + * remoteChainSelector: "10344971235874465080", // Base Sepolia + * remoteChainType: "evm", + * remotePoolAddresses: ["0x5555555555555555555555555555555555555555"], + * remoteTokenAddress: "0x6666666666666666666666666666666666666666", + * outboundRateLimiterConfig: { isEnabled: true, capacity: "500000", rate: "50000" }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: "500000", rate: "50000" } + * }] + * ]); + * + * const transaction = await generator.generate(inputJson); + * transaction.to = poolAddress; + * // This will atomically remove one chain and add another + * ``` + * + * @throws {ChainUpdateError} When input JSON validation fails + * @throws {ChainUpdateError} When chain type is unsupported (MVM) + * @throws {ChainUpdateError} When address encoding fails + * @throws {ChainUpdateError} When transaction encoding fails + * + * @see {@link ChainUpdateGenerator} for interface definition + * @see {@link chainUpdatesInputSchema} for input validation schema + * @see {@link convertToContractFormat} for address encoding logic + * @see {@link ChainType} for supported chain types + * + * @public + */ +export function createChainUpdateGenerator( + logger: ILogger, + interfaceProvider: IInterfaceProvider, +): ChainUpdateGenerator { + return { + async generate(inputJson: string): Promise { + // Parse and validate input + const parsedInput = await executeAsync( + async () => chainUpdatesInputSchema.parseAsync(JSON.parse(inputJson)), + ChainUpdateError, + 'Invalid input format', + { inputJson }, + ); - // Create the contract interface and encode the function call - const poolInterface = TokenPool__factory.createInterface(); - const data = poolInterface.encodeFunctionData('applyChainUpdates', [ - chainSelectorsToRemove, - chainsToAdd.map(convertToContractFormat), - ]); + logger.info('Successfully validated chain updates input', { + chainsToRemoveCount: parsedInput[0].length, + chainsToAddCount: parsedInput[1].length, + }); - logger.info('Successfully generated chain update transaction'); + try { + // Convert the validated input to contract format + const [chainSelectorsToRemove, chainsToAdd] = parsedInput; - return { - to: '', // To be filled by the caller - value: '0', - data, - operation: SafeOperationType.Call, - }; - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to generate transaction', { error }); - throw new ChainUpdateError(`Failed to generate transaction: ${error.message}`); - } - throw error; - } -} + // Create the contract interface and encode the function call + const poolInterface = interfaceProvider.getTokenPoolInterface(); + const data = poolInterface.encodeFunctionData('applyChainUpdates', [ + chainSelectorsToRemove, + chainsToAdd.map(convertToContractFormat), + ]); -/** - * Creates a Safe Transaction Builder JSON for the chain updates - * @param transaction - The Safe transaction data - * @param metadata - The Safe metadata - * @returns The Safe Transaction Builder JSON - */ -export function createChainUpdateJSON( - transaction: SafeTransactionDataBase, - metadata: SafeChainUpdateMetadata, -): SafeTransactionBuilderJSON { - const poolInterface = TokenPool__factory.createInterface(); - const methodFragment = poolInterface.getFunction('applyChainUpdates'); + logger.info('Successfully generated chain update transaction'); - return { - version: '1.0', - chainId: metadata.chainId, - createdAt: Date.now(), - meta: { - name: 'Token Pool Chain Updates', - description: 'Apply chain updates to the Token Pool contract', - txBuilderVersion: SAFE_TX_BUILDER_VERSION, - createdFromSafeAddress: metadata.safeAddress, - createdFromOwnerAddress: metadata.ownerAddress, + return { + to: '', // To be filled by the caller + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }; + } catch (error) { + logError(error, 'generate chain update transaction'); + throw error instanceof Error + ? new ChainUpdateError('Failed to generate transaction', undefined, error) + : error; + } }, - transactions: [ - { - to: metadata.tokenPoolAddress, - value: transaction.value, - data: transaction.data, - operation: transaction.operation, - contractMethod: { - inputs: methodFragment.inputs.map((input) => ({ - name: input.name, - type: input.type, - internalType: input.type, - })), - name: methodFragment.name, - payable: methodFragment.payable, - }, - contractInputsValues: null, - }, - ], }; } diff --git a/src/generators/poolDeployment.ts b/src/generators/poolDeployment.ts index 310b3a0..3fa4c41 100644 --- a/src/generators/poolDeployment.ts +++ b/src/generators/poolDeployment.ts @@ -1,161 +1,233 @@ +/** + * @fileoverview Token pool deployment transaction generator for existing tokens. + * + * This module generates transactions for deploying TokenPool contracts (either BurnMintTokenPool + * or LockReleaseTokenPool) for existing token contracts via the TokenPoolFactory using CREATE2 + * for deterministic addresses. Supports cross-chain configuration with remote pool setups. + * + * @module generators/poolDeployment + */ + import { ethers } from 'ethers'; -import { BYTECODES } from '../constants/bytecodes'; +import { BYTECODES, DEFAULTS } from '../config'; import { - PoolDeploymentParams, poolDeploymentParamsSchema, ContractRemoteTokenPoolInfo, RemoteTokenPoolInfo, } from '../types/poolDeployment'; -import { - SafeMetadata, - SafeTransactionDataBase, - SafeTransactionBuilderJSON, - SAFE_TX_BUILDER_VERSION, - SafeOperationType, -} from '../types/safe'; -import { TokenPoolFactory__factory } from '../typechain'; +import { SafeTransactionDataBase, SafeOperationType } from '../types/safe'; import { poolTypeToNumber } from '../utils/poolTypeConverter'; -import logger from '../utils/logger'; +import { PoolDeploymentError } from '../errors'; +import { executeAsync, logError } from '../errors/AsyncErrorHandler'; +import { ILogger, IInterfaceProvider } from '../interfaces'; -export class PoolDeploymentError extends Error { - constructor(message: string) { - super(message); - this.name = 'PoolDeploymentError'; - } +/** + * Generator interface for token pool deployment transactions. + * + * Generates transactions that deploy TokenPool contracts for existing token contracts + * through the TokenPoolFactory. Supports both BurnMintTokenPool (for mintable/burnable tokens) + * and LockReleaseTokenPool (for lock-and-release mechanisms) pool types. + * + * @remarks + * The generator validates input parameters, selects the appropriate pool bytecode based on + * pool type, converts pool type enums to contract-compatible numbers, and generates the + * factory deployment transaction with optional remote chain configurations. + * + * @public + */ +export interface PoolDeploymentGenerator { + /** + * Generates a token pool deployment transaction for an existing token. + * + * @param inputJson - JSON string containing pool deployment parameters + * @param factoryAddress - Address of the TokenPoolFactory contract + * @param salt - 32-byte salt for CREATE2 deterministic deployment (hex string with 0x prefix) + * + * @returns Transaction data containing target address, encoded function call, and operation type + * + * @throws {PoolDeploymentError} When validation fails or transaction generation fails + * + * @see {@link poolDeploymentParamsSchema} for input JSON schema + * @see {@link SafeTransactionDataBase} for return type structure + */ + generate( + inputJson: string, + factoryAddress: string, + salt: string, + ): Promise; } /** - * Generates a deployment transaction for pool only using TokenPoolFactory - * @param inputJson - The input JSON string containing deployment parameters - * @param factoryAddress - The address of the TokenPoolFactory contract - * @param salt - The salt to use for create2 deployment (required) - * @returns The Safe transaction data + * Creates a token pool deployment generator. + * + * Factory function that creates a generator for deploying TokenPool contracts for existing + * tokens via the TokenPoolFactory. Supports two pool types: BurnMintTokenPool for tokens + * that can be minted and burned, and LockReleaseTokenPool for tokens that use a lock-and-release + * mechanism for cross-chain transfers. + * + * @param logger - Logger instance for operation logging and debugging + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPoolFactory) + * + * @returns Generator instance implementing {@link PoolDeploymentGenerator} interface + * + * @remarks + * The generator follows this process: + * 1. Validates input JSON against Zod schema + * 2. Validates factory address + * 3. Converts pool type string to numeric enum (BurnMintTokenPool=0, LockReleaseTokenPool=1) + * 4. Selects appropriate pool bytecode based on pool type + * 5. Processes remote token pool configurations (if provided) + * 6. Encodes factory `deployTokenPoolWithExistingToken` function call + * 7. Returns transaction data ready for execution + * + * Pool Type Selection: + * - **BurnMintTokenPool**: For tokens with mint/burn capabilities. Tokens are burned on the + * source chain and minted on the destination chain during cross-chain transfers. + * - **LockReleaseTokenPool**: For tokens without mint/burn. Tokens are locked on one chain + * and equivalent amounts are released on another chain. + * + * @example + * ```typescript + * const generator = createPoolDeploymentGenerator( + * logger, + * interfaceProvider + * ); + * + * // Deploy BurnMintTokenPool for an existing token + * const inputJson = JSON.stringify({ + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * decimals: 18, + * poolType: "BurnMintTokenPool", + * remoteTokenPools: [ + * { + * remoteChainSelector: "16015286601757825753", + * remotePoolAddress: "0x1234567890123456789012345678901234567890", + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * poolType: "BurnMintTokenPool" + * } + * ] + * }); + * + * const transaction = await generator.generate( + * inputJson, + * "0x17d8a409fe2cef2d3808bcb61f14abeffc28876e", // factory + * "0x0000000000000000000000000000000000000000000000000000000123456789" // salt + * ); + * + * console.log(transaction.to); // Factory address + * console.log(transaction.data); // Encoded deployTokenPoolWithExistingToken call + * console.log(transaction.value); // "0" (no ETH sent) + * console.log(transaction.operation); // SafeOperationType.Call + * ``` + * + * @example + * ```typescript + * // Deploy LockReleaseTokenPool for an existing token + * const inputJson = JSON.stringify({ + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * decimals: 18, + * poolType: "LockReleaseTokenPool", + * remoteTokenPools: [] + * }); + * + * const transaction = await generator.generate( + * inputJson, + * "0x17d8a409fe2cef2d3808bcb61f14abeffc28876e", + * "0x0000000000000000000000000000000000000000000000000000000123456789" + * ); + * ``` + * + * @throws {PoolDeploymentError} When factory address is invalid + * @throws {PoolDeploymentError} When salt is missing or invalid + * @throws {PoolDeploymentError} When input JSON validation fails + * @throws {PoolDeploymentError} When transaction encoding fails + * + * @see {@link PoolDeploymentGenerator} for interface definition + * @see {@link poolDeploymentParamsSchema} for input validation schema + * @see {@link poolTypeToNumber} for pool type conversion + * @see {@link BYTECODES} for pool contract bytecodes + * + * @public */ -export async function generatePoolDeploymentTransaction( - inputJson: string, - factoryAddress: string, - salt: string, -): Promise { - if (!ethers.isAddress(factoryAddress)) { - throw new PoolDeploymentError('Invalid factory address'); - } - - if (!salt) { - throw new PoolDeploymentError('Salt is required for deployment'); - } +export function createPoolDeploymentGenerator( + logger: ILogger, + interfaceProvider: IInterfaceProvider, +): PoolDeploymentGenerator { + return { + async generate( + inputJson: string, + factoryAddress: string, + salt: string, + ): Promise { + if (!ethers.isAddress(factoryAddress)) { + throw new PoolDeploymentError('Invalid factory address'); + } - let parsedInput: PoolDeploymentParams; + if (!salt) { + throw new PoolDeploymentError('Salt is required for deployment'); + } - try { - // Parse and validate the input JSON - const rawInput = JSON.parse(inputJson) as unknown; - parsedInput = await poolDeploymentParamsSchema.parseAsync(rawInput); - logger.info('Successfully validated pool deployment input', { - poolType: parsedInput.poolType, - token: parsedInput.token, - }); - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to parse or validate input JSON', { error, inputJson }); - throw new PoolDeploymentError(`Invalid input format: ${error.message}`); - } - throw error; - } + // Parse and validate input + const parsedInput = await executeAsync( + async () => poolDeploymentParamsSchema.parseAsync(JSON.parse(inputJson)), + PoolDeploymentError, + 'Invalid input format', + { inputJson }, + ); - try { - // Get the factory interface - const factoryInterface = TokenPoolFactory__factory.createInterface(); + logger.info('Successfully validated pool deployment input', { + poolType: parsedInput.poolType, + token: parsedInput.token, + }); - // Convert pool type enum to contract value - const poolTypeValue = poolTypeToNumber(parsedInput.poolType); + try { + // Get the factory interface + const factoryInterface = interfaceProvider.getTokenPoolFactoryInterface(); - // Convert remote token pools' pool types to contract values - const remoteTokenPools: ContractRemoteTokenPoolInfo[] = parsedInput.remoteTokenPools.map( - (pool: RemoteTokenPoolInfo) => ({ - ...pool, - poolType: poolTypeToNumber(pool.poolType), - }), - ); + // Convert pool type enum to contract value + const poolTypeValue = poolTypeToNumber(parsedInput.poolType); - // Get the appropriate pool bytecode - const poolBytecode = - parsedInput.poolType === 'BurnMintTokenPool' - ? BYTECODES.BURN_MINT_TOKEN_POOL - : BYTECODES.LOCK_RELEASE_TOKEN_POOL; + // Convert remote token pools' pool types to contract values + const remoteTokenPools: ContractRemoteTokenPoolInfo[] = parsedInput.remoteTokenPools.map( + (pool: RemoteTokenPoolInfo) => ({ + ...pool, + poolType: poolTypeToNumber(pool.poolType), + }), + ); - // Encode the function call to deployTokenPoolWithExistingToken - const data = factoryInterface.encodeFunctionData('deployTokenPoolWithExistingToken', [ - parsedInput.token, - parsedInput.decimals, - remoteTokenPools, - poolBytecode, - salt, - poolTypeValue, - ]); + // Get the appropriate pool bytecode + const poolBytecode = + parsedInput.poolType === 'BurnMintTokenPool' + ? BYTECODES.BURN_MINT_TOKEN_POOL + : BYTECODES.LOCK_RELEASE_TOKEN_POOL; - logger.info('Successfully generated pool deployment transaction'); + // Encode the function call to deployTokenPoolWithExistingToken + const data = factoryInterface.encodeFunctionData('deployTokenPoolWithExistingToken', [ + parsedInput.token, + parsedInput.decimals, + remoteTokenPools, + poolBytecode, + salt, + poolTypeValue, + ]); - return { - to: factoryAddress, - value: '0', - data, - operation: SafeOperationType.Call, - }; - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to generate deployment transaction', { error }); - throw new PoolDeploymentError(`Failed to generate deployment transaction: ${error.message}`); - } - throw error; - } -} + logger.info('Successfully generated pool deployment transaction'); -/** - * Creates a Safe Transaction Builder JSON for the pool deployment - * @param transaction - The Safe transaction data - * @param params - The pool deployment parameters - * @param metadata - The Safe metadata - * @returns The Safe Transaction Builder JSON - */ -export function createPoolDeploymentJSON( - transaction: SafeTransactionDataBase, - params: PoolDeploymentParams, - metadata: SafeMetadata, -): SafeTransactionBuilderJSON { - // Get the factory interface - const factoryInterface = TokenPoolFactory__factory.createInterface(); - - // Get the deployTokenPoolWithExistingToken function fragment - const methodFragment = factoryInterface.getFunction('deployTokenPoolWithExistingToken'); - - return { - version: '1.0', - chainId: metadata.chainId, - createdAt: Date.now(), - meta: { - name: `Pool Factory Deployment - ${params.poolType}`, - description: `Deploy ${params.poolType} for token at ${params.token} using factory`, - txBuilderVersion: SAFE_TX_BUILDER_VERSION, - createdFromSafeAddress: metadata.safeAddress, - createdFromOwnerAddress: metadata.ownerAddress, + return { + to: factoryAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }; + } catch (error) { + logError(error, 'generate pool deployment transaction', { + factoryAddress, + salt, + }); + throw error instanceof Error + ? new PoolDeploymentError('Failed to generate deployment transaction', undefined, error) + : error; + } }, - transactions: [ - { - to: transaction.to, - value: transaction.value, - data: transaction.data, - operation: transaction.operation, - contractMethod: { - inputs: methodFragment.inputs.map((input) => ({ - name: input.name, - type: input.type, - internalType: input.type, - })), - name: methodFragment.name, - payable: methodFragment.payable, - }, - contractInputsValues: null, - }, - ], }; } diff --git a/src/generators/rateLimiterConfig.ts b/src/generators/rateLimiterConfig.ts new file mode 100644 index 0000000..c3be521 --- /dev/null +++ b/src/generators/rateLimiterConfig.ts @@ -0,0 +1,279 @@ +/** + * @fileoverview Rate limiter configuration transaction generator for token pools. + * + * This module generates transactions for configuring per-chain rate limiters on token + * pool contracts. Rate limiters control the maximum rate and capacity of token transfers + * to/from specific remote chains, providing protection against exploits and ensuring + * controlled cross-chain token flow. + * + * @module generators/rateLimiterConfig + */ + +import { ethers } from 'ethers'; +import { setChainRateLimiterConfigSchema } from '../types/rateLimiter'; +import { SafeTransactionDataBase, SafeOperationType } from '../types/safe'; +import { DEFAULTS } from '../config'; +import { RateLimiterConfigError } from '../errors'; +import { executeAsync, logError } from '../errors/AsyncErrorHandler'; +import { ILogger, IInterfaceProvider } from '../interfaces'; + +/** + * Generator interface for token pool rate limiter configuration transactions. + * + * Generates transactions for configuring rate limiters on token pool contracts for + * specific remote chains. Each chain connection has independent inbound and outbound + * rate limiters that control token transfer rates and capacities. + * + * @remarks + * The generator validates input parameters, encodes the `setChainRateLimiterConfig` + * function call with chain selector and rate limiter configurations, and returns + * transaction data ready for execution. + * + * @public + */ +export interface RateLimiterConfigGenerator { + /** + * Generates a rate limiter configuration transaction for a specific chain. + * + * @param inputJson - JSON string containing chain selector and rate limiter configurations + * @param tokenPoolAddress - Address of the TokenPool contract + * + * @returns Transaction data containing target address, encoded function call, and operation type + * + * @throws {RateLimiterConfigError} When validation fails or transaction generation fails + * + * @see {@link setChainRateLimiterConfigSchema} for input JSON schema + * @see {@link SafeTransactionDataBase} for return type structure + */ + generate(inputJson: string, tokenPoolAddress: string): Promise; +} + +/** + * Creates a rate limiter configuration transaction generator. + * + * Factory function that creates a generator for configuring per-chain rate limiters on + * token pools. Rate limiters implement a token bucket algorithm to control transfer rates + * and capacities, providing security and risk management for cross-chain token transfers. + * + * @param logger - Logger instance for operation logging and debugging + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPool) + * + * @returns Generator instance implementing {@link RateLimiterConfigGenerator} interface + * + * @remarks + * The generator follows this process: + * 1. Validates token pool address format + * 2. Validates input JSON against Zod schema (chain selector, configs) + * 3. Encodes TokenPool `setChainRateLimiterConfig` function call + * 4. Returns transaction data ready for execution + * + * Rate Limiter Concepts: + * - **Capacity**: Maximum token amount that can accumulate in the bucket (string, in token wei) + * - **Rate**: Token refill rate per second (string, in token wei per second) + * - **Inbound**: Limits tokens coming into this chain from the remote chain + * - **Outbound**: Limits tokens going out of this chain to the remote chain + * - **Enabled**: Boolean flag to enable/disable the rate limiter + * + * Token Bucket Algorithm: + * - Bucket starts at capacity + * - Each transfer consumes tokens from the bucket + * - Bucket refills at the specified rate per second + * - Transfers fail if bucket doesn't have enough tokens + * - Prevents burst transfers beyond capacity + * + * Common Configurations: + * - **Conservative**: Low rate, low capacity (high security, low throughput) + * - **Moderate**: Balanced rate and capacity (typical production) + * - **Permissive**: High rate, high capacity (testing, high-volume use cases) + * - **Disabled**: isEnabled=false (no rate limiting, maximum risk) + * + * @example + * ```typescript + * const generator = createRateLimiterConfigGenerator(logger, interfaceProvider); + * + * // Configure moderate rate limiting for Ethereum Sepolia + * const inputJson = JSON.stringify({ + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * outboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens max bucket + * rate: "100000000000000000" // 0.1 tokens per second refill + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens max bucket + * rate: "100000000000000000" // 0.1 tokens per second refill + * } + * }); + * + * const transaction = await generator.generate( + * inputJson, + * "0x779877A7B0D9E8603169DdbD7836e478b4624789" // Pool address + * ); + * + * console.log(transaction.to); // Pool contract address + * console.log(transaction.data); // Encoded setChainRateLimiterConfig call + * console.log(transaction.value); // "0" (no ETH sent) + * console.log(transaction.operation); // SafeOperationType.Call + * + * // Execute via Safe multisig + * ``` + * + * @example + * ```typescript + * // Disable rate limiting for a chain (maximum risk) + * const inputJson = JSON.stringify({ + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * }, + * inboundConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * } + * }); + * + * const transaction = await generator.generate(inputJson, poolAddress); + * // No rate limiting - all transfers allowed (use with caution) + * ``` + * + * @example + * ```typescript + * // Conservative configuration for high-security chain + * const inputJson = JSON.stringify({ + * remoteChainSelector: "10344971235874465080", // Base Sepolia + * outboundConfig: { + * isEnabled: true, + * capacity: "100000000000000000000", // 100 tokens max + * rate: "10000000000000000" // 0.01 tokens/sec (slow refill) + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "100000000000000000000", // 100 tokens max + * rate: "10000000000000000" // 0.01 tokens/sec (slow refill) + * } + * }); + * + * const transaction = await generator.generate(inputJson, poolAddress); + * ``` + * + * @example + * ```typescript + * // Asymmetric configuration: strict outbound, lenient inbound + * const inputJson = JSON.stringify({ + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: true, + * capacity: "500000000000000000000", // 500 tokens max outbound + * rate: "50000000000000000" // 0.05 tokens/sec outbound + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "5000000000000000000000", // 5000 tokens max inbound + * rate: "500000000000000000" // 0.5 tokens/sec inbound + * } + * }); + * + * const transaction = await generator.generate(inputJson, poolAddress); + * // Allows more tokens to come in than go out + * ``` + * + * @example + * ```typescript + * // Complete workflow: Configure rate limiting after chain addition + * // Step 1: Add new chain configuration + * const chainUpdateGen = createChainUpdateGenerator(logger, interfaceProvider); + * const chainUpdateTx = await chainUpdateGen.generate(chainUpdateJson); + * chainUpdateTx.to = poolAddress; + * // Execute chain update... + * + * // Step 2: Configure rate limiters for the new chain + * const rateLimiterGen = createRateLimiterConfigGenerator(logger, interfaceProvider); + * const rateLimiterTx = await rateLimiterGen.generate( + * JSON.stringify({ + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { isEnabled: true, capacity: "1000000", rate: "100000" }, + * inboundConfig: { isEnabled: true, capacity: "1000000", rate: "100000" } + * }), + * poolAddress + * ); + * // Execute rate limiter config... + * + * // Step 3: Cross-chain transfers now rate-limited + * ``` + * + * @throws {RateLimiterConfigError} When token pool address is invalid + * @throws {RateLimiterConfigError} When input JSON validation fails + * @throws {RateLimiterConfigError} When chain selector is invalid + * @throws {RateLimiterConfigError} When capacity or rate values are invalid (negative, non-numeric) + * @throws {RateLimiterConfigError} When transaction encoding fails + * + * @see {@link RateLimiterConfigGenerator} for interface definition + * @see {@link setChainRateLimiterConfigSchema} for input validation schema + * + * @public + */ +export function createRateLimiterConfigGenerator( + logger: ILogger, + interfaceProvider: IInterfaceProvider, +): RateLimiterConfigGenerator { + return { + async generate(inputJson: string, tokenPoolAddress: string): Promise { + if (!ethers.isAddress(tokenPoolAddress)) { + throw new RateLimiterConfigError('Invalid token pool address'); + } + + // Parse and validate input + const parsedInput = await executeAsync( + async () => setChainRateLimiterConfigSchema.parseAsync(JSON.parse(inputJson)), + RateLimiterConfigError, + 'Invalid input format', + { inputJson }, + ); + + logger.info('Successfully validated rate limiter configuration input', { + remoteChainSelector: parsedInput.remoteChainSelector, + outboundConfig: parsedInput.outboundConfig, + inboundConfig: parsedInput.inboundConfig, + }); + + try { + const poolInterface = interfaceProvider.getTokenPoolInterface(); + + const data = poolInterface.encodeFunctionData('setChainRateLimiterConfig', [ + parsedInput.remoteChainSelector, + parsedInput.outboundConfig, + parsedInput.inboundConfig, + ]); + + logger.info('Successfully generated rate limiter configuration transaction', { + tokenPoolAddress, + remoteChainSelector: parsedInput.remoteChainSelector, + outboundConfig: parsedInput.outboundConfig, + inboundConfig: parsedInput.inboundConfig, + }); + + return { + to: tokenPoolAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }; + } catch (error) { + logError(error, 'generate rate limiter configuration transaction', { + tokenPoolAddress, + }); + throw error instanceof Error + ? new RateLimiterConfigError( + 'Failed to generate rate limiter configuration transaction', + undefined, + error, + ) + : error; + } + }, + }; +} diff --git a/src/generators/roleManagement.ts b/src/generators/roleManagement.ts new file mode 100644 index 0000000..9754e78 --- /dev/null +++ b/src/generators/roleManagement.ts @@ -0,0 +1,359 @@ +/** + * @fileoverview Role management transaction generator for BurnMintERC20 tokens. + * + * This module generates transactions for granting and revoking MINTER_ROLE and BURNER_ROLE + * on BurnMintERC20 token contracts. Essential for configuring token pool permissions and + * enabling cross-chain token transfers with BurnMintTokenPool contracts. + * + * @module generators/roleManagement + */ + +import { ethers } from 'ethers'; +import { roleManagementParamsSchema } from '../types/tokenMint'; +import { SafeOperationType, SafeTransactionDataBase } from '../types/safe'; +import { DEFAULTS } from '../config'; +import { RoleManagementError } from '../errors'; +import { executeAsync, logError } from '../errors/AsyncErrorHandler'; +import { ILogger, IInterfaceProvider } from '../interfaces'; + +/** + * Mapping of role operations to BurnMintERC20 contract function names. + * + * This constant maps role management operations to their corresponding contract functions, + * enabling type-safe function name resolution during transaction generation. + * + * @remarks + * Grant operations: + * - `grantMintRole`: Grants MINTER_ROLE to an address + * - `grantBurnRole`: Grants BURNER_ROLE to an address + * - `grantMintAndBurnRoles`: Grants both roles in a single transaction (gas efficient) + * + * Revoke operations: + * - `revokeMintRole`: Revokes MINTER_ROLE from an address + * - `revokeBurnRole`: Revokes BURNER_ROLE from an address + * - Note: No combined revoke function exists; must call both separately for "both" + * + * @internal + */ +const ROLE_FUNCTION_NAMES = { + grantMint: 'grantMintRole', + grantBurn: 'grantBurnRole', + grantBoth: 'grantMintAndBurnRoles', + revokeMint: 'revokeMintRole', + revokeBurn: 'revokeBurnRole', +} as const; + +/** + * Type representing valid BurnMintERC20 role management function names. + * + * Derived from {@link ROLE_FUNCTION_NAMES} to ensure type safety and consistency + * between the mapping and actual contract function calls. + * + * @public + */ +export type RoleFunctionName = (typeof ROLE_FUNCTION_NAMES)[keyof typeof ROLE_FUNCTION_NAMES]; + +/** + * Result type for role management transaction generation. + * + * Role management operations can generate either a single transaction (for grant operations + * and single-role revokes) or multiple transactions (for revoking both roles, which requires + * separate function calls). + * + * @remarks + * Transaction Count by Operation: + * - Grant mint: 1 transaction + * - Grant burn: 1 transaction + * - Grant both: 1 transaction (uses combined function) + * - Revoke mint: 1 transaction + * - Revoke burn: 1 transaction + * - Revoke both: 2 transactions (no combined function exists) + * + * @public + */ +export interface RoleManagementTransactionResult { + /** + * Array of transaction data objects ready for execution. + * + * Contains 1 transaction for single operations, or 2 transactions when revoking both roles. + */ + transactions: SafeTransactionDataBase[]; + + /** + * Array of contract function names corresponding to each transaction. + * + * Useful for logging, UI display, and transaction verification. + */ + functionNames: RoleFunctionName[]; +} + +/** + * Generator interface for token role management transactions. + * + * Generates transactions for granting and revoking MINTER_ROLE and BURNER_ROLE on + * BurnMintERC20 token contracts. Essential for configuring permissions before token + * minting/burning and enabling cross-chain transfers with BurnMintTokenPool. + * + * @remarks + * Role management is required for: + * - Granting MINTER_ROLE to Safe multisig for manual token minting + * - Granting both roles to BurnMintTokenPool for cross-chain transfers + * - Revoking roles when decommissioning pools or changing permissions + * + * @public + */ +export interface RoleManagementGenerator { + /** + * Generates role management transaction(s) for a BurnMintERC20 token. + * + * @param inputJson - JSON string containing role management parameters + * @param tokenAddress - Address of the BurnMintERC20 token contract + * + * @returns Result containing one or more transactions and their function names + * + * @throws {RoleManagementError} When validation fails or transaction generation fails + * + * @see {@link roleManagementParamsSchema} for input JSON schema + * @see {@link RoleManagementTransactionResult} for return type structure + */ + generate(inputJson: string, tokenAddress: string): Promise; +} + +/** + * Creates a role management transaction generator. + * + * Factory function that creates a generator for granting and revoking MINTER_ROLE and + * BURNER_ROLE on BurnMintERC20 token contracts. Supports granting/revoking individual + * roles or both roles simultaneously. + * + * @param logger - Logger instance for operation logging and debugging + * @param interfaceProvider - Provider for contract ABI interfaces (BurnMintERC20) + * + * @returns Generator instance implementing {@link RoleManagementGenerator} interface + * + * @remarks + * The generator follows this process: + * 1. Validates token address format + * 2. Validates input JSON against Zod schema (pool/grantee, roleType, action) + * 3. Selects appropriate contract function(s) based on action and role type + * 4. Encodes function call(s) with pool/grantee address + * 5. Returns transaction data ready for execution + * + * Role Types: + * - **mint**: MINTER_ROLE only (allows minting tokens) + * - **burn**: BURNER_ROLE only (allows burning tokens) + * - **both**: Both MINTER_ROLE and BURNER_ROLE (most common for pools) + * + * Actions: + * - **grant**: Add role(s) to an address + * - "both" uses single `grantMintAndBurnRoles` call (gas efficient) + * - **revoke**: Remove role(s) from an address + * - "both" requires two separate calls (no combined function) + * + * Common Use Cases: + * 1. **Grant both roles to pool**: Required for BurnMintTokenPool to function + * 2. **Grant mint to Safe**: Allows Safe to mint tokens manually + * 3. **Revoke roles from old pool**: When migrating to a new pool contract + * + * @example + * ```typescript + * const generator = createRoleManagementGenerator(logger, interfaceProvider); + * + * // Grant both mint and burn roles to a BurnMintTokenPool (most common) + * const inputJson = JSON.stringify({ + * pool: "0x1234567890123456789012345678901234567890", // Pool address + * roleType: "both", + * action: "grant" + * }); + * + * const result = await generator.generate( + * inputJson, + * "0x779877A7B0D9E8603169DdbD7836e478b4624789" // Token address + * ); + * + * console.log(result.transactions.length); // 1 (uses combined function) + * console.log(result.functionNames); // ['grantMintAndBurnRoles'] + * console.log(result.transactions[0].to); // Token contract address + * console.log(result.transactions[0].data); // Encoded function call + * + * // Execute transaction(s) via Safe multisig + * ``` + * + * @example + * ```typescript + * // Grant only MINTER_ROLE to Safe multisig + * const inputJson = JSON.stringify({ + * pool: "0xSafeAddress", + * roleType: "mint", + * action: "grant" + * }); + * + * const result = await generator.generate(inputJson, tokenAddress); + * console.log(result.transactions.length); // 1 + * console.log(result.functionNames); // ['grantMintRole'] + * ``` + * + * @example + * ```typescript + * // Revoke both roles from an address (generates 2 transactions) + * const inputJson = JSON.stringify({ + * pool: "0xOldPoolAddress", + * roleType: "both", + * action: "revoke" + * }); + * + * const result = await generator.generate(inputJson, tokenAddress); + * console.log(result.transactions.length); // 2 (separate calls required) + * console.log(result.functionNames); // ['revokeMintRole', 'revokeBurnRole'] + * + * // Both transactions must be executed (can be batched in Safe) + * ``` + * + * @example + * ```typescript + * // Complete workflow: Deploy pool, grant roles, verify + * // Step 1: Deploy BurnMintTokenPool + * const poolGenerator = createPoolDeploymentGenerator(logger, interfaceProvider); + * const poolDeployTx = await poolGenerator.generate(poolInputJson, factoryAddress, salt); + * // Execute and get deployed pool address... + * + * // Step 2: Grant both roles to the pool + * const roleGenerator = createRoleManagementGenerator(logger, interfaceProvider); + * const grantRoleResult = await roleGenerator.generate( + * JSON.stringify({ + * pool: deployedPoolAddress, + * roleType: "both", + * action: "grant" + * }), + * tokenAddress + * ); + * // Execute grant transaction... + * + * // Step 3: Pool can now mint and burn tokens for cross-chain transfers + * ``` + * + * @throws {RoleManagementError} When token address is invalid + * @throws {RoleManagementError} When input JSON validation fails + * @throws {RoleManagementError} When pool/grantee address is invalid + * @throws {RoleManagementError} When roleType is invalid (must be 'mint', 'burn', or 'both') + * @throws {RoleManagementError} When action is invalid (must be 'grant' or 'revoke') + * @throws {RoleManagementError} When transaction encoding fails + * + * @see {@link RoleManagementGenerator} for interface definition + * @see {@link roleManagementParamsSchema} for input validation schema + * @see {@link RoleManagementTransactionResult} for return type structure + * @see {@link ROLE_FUNCTION_NAMES} for function name mapping + * + * @public + */ +export function createRoleManagementGenerator( + logger: ILogger, + interfaceProvider: IInterfaceProvider, +): RoleManagementGenerator { + return { + async generate( + inputJson: string, + tokenAddress: string, + ): Promise { + if (!ethers.isAddress(tokenAddress)) { + throw new RoleManagementError('Invalid token address'); + } + + // Parse and validate input + const parsedInput = await executeAsync( + async () => roleManagementParamsSchema.parseAsync(JSON.parse(inputJson)), + RoleManagementError, + 'Invalid input format', + { inputJson }, + ); + + logger.info('Successfully validated role management input', { + pool: parsedInput.pool, + roleType: parsedInput.roleType, + action: parsedInput.action, + }); + + try { + const tokenInterface = interfaceProvider.getFactoryBurnMintERC20Interface(); + const transactions: SafeTransactionDataBase[] = []; + const functionNames: RoleFunctionName[] = []; + + // Determine which functions to call based on action and role type + if (parsedInput.action === 'grant') { + // Grant operations: can use combined function for 'both' + let data: string; + let functionName: RoleFunctionName; + + switch (parsedInput.roleType) { + case 'mint': + functionName = ROLE_FUNCTION_NAMES.grantMint; + data = tokenInterface.encodeFunctionData(functionName, [parsedInput.pool]); + break; + case 'burn': + functionName = ROLE_FUNCTION_NAMES.grantBurn; + data = tokenInterface.encodeFunctionData(functionName, [parsedInput.pool]); + break; + case 'both': + default: + functionName = ROLE_FUNCTION_NAMES.grantBoth; + data = tokenInterface.encodeFunctionData(functionName, [parsedInput.pool]); + break; + } + + transactions.push({ + to: tokenAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }); + functionNames.push(functionName); + } else { + // Revoke operations: must call separate functions for 'both' + if (parsedInput.roleType === 'mint' || parsedInput.roleType === 'both') { + const functionName = ROLE_FUNCTION_NAMES.revokeMint; + const data = tokenInterface.encodeFunctionData(functionName, [parsedInput.pool]); + transactions.push({ + to: tokenAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }); + functionNames.push(functionName); + } + + if (parsedInput.roleType === 'burn' || parsedInput.roleType === 'both') { + const functionName = ROLE_FUNCTION_NAMES.revokeBurn; + const data = tokenInterface.encodeFunctionData(functionName, [parsedInput.pool]); + transactions.push({ + to: tokenAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }); + functionNames.push(functionName); + } + } + + logger.info('Successfully generated role management transaction(s)', { + tokenAddress, + pool: parsedInput.pool, + roleType: parsedInput.roleType, + action: parsedInput.action, + functionNames, + transactionCount: transactions.length, + }); + + return { transactions, functionNames }; + } catch (error) { + logError(error, 'generate role management transaction', { tokenAddress }); + throw error instanceof Error + ? new RoleManagementError( + 'Failed to generate role management transaction', + undefined, + error, + ) + : error; + } + }, + }; +} diff --git a/src/generators/tokenDeployment.ts b/src/generators/tokenDeployment.ts index d2def9d..2176876 100644 --- a/src/generators/tokenDeployment.ts +++ b/src/generators/tokenDeployment.ts @@ -1,175 +1,238 @@ +/** + * @fileoverview Token and pool deployment transaction generator. + * + * This module generates transactions for deploying both a BurnMintERC20 token and its + * associated TokenPool contract via the TokenPoolFactory using CREATE2 for deterministic + * addresses. Supports cross-chain configuration with remote token pools. + * + * @module generators/tokenDeployment + */ + import { ethers } from 'ethers'; -import { BYTECODES } from '../constants/bytecodes'; -import { TokenDeploymentParams, tokenDeploymentParamsSchema } from '../types/tokenDeployment'; -import { - SafeOperationType, - SafeTransactionDataBase, - SafeTransactionBuilderJSON, - SAFE_TX_BUILDER_VERSION, - SafeMetadata, -} from '../types/safe'; -import { TokenPoolFactory__factory, FactoryBurnMintERC20__factory } from '../typechain'; -import { computeCreate2Address } from '../utils/addressComputer'; -import logger from '../utils/logger'; - -export class TokenDeploymentError extends Error { - constructor(message: string) { - super(message); - this.name = 'TokenDeploymentError'; - } -} +import { BYTECODES, DEFAULTS } from '../config'; +import { tokenDeploymentParamsSchema } from '../types/tokenDeployment'; +import { SafeOperationType, SafeTransactionDataBase } from '../types/safe'; +import { TokenDeploymentError } from '../errors'; +import { executeAsync, logError } from '../errors/AsyncErrorHandler'; +import { ILogger, IInterfaceProvider, IAddressComputer } from '../interfaces'; /** - * Generates a deployment transaction for both token and pool using TokenPoolFactory - * @param inputJson - The input JSON string containing deployment parameters - * @param factoryAddress - The address of the TokenPoolFactory contract - * @param salt - The salt to use for create2 deployment (required) - * @param safeAddress - The address of the Safe that will execute the transaction - * @returns The Safe transaction data + * Generator interface for token and pool deployment transactions. + * + * Generates transactions that deploy both a BurnMintERC20 token and its associated + * BurnMintTokenPool contract through the TokenPoolFactory contract using CREATE2 + * for deterministic address computation. + * + * @remarks + * The generator validates input parameters, encodes token constructor arguments, + * computes CREATE2 deterministic addresses, and generates the factory deployment + * transaction. The resulting transaction can be executed via Safe multisig or + * directly submitted to the blockchain. + * + * @public */ -export async function generateTokenAndPoolDeployment( - inputJson: string, - factoryAddress: string, - salt: string, - safeAddress: string, -): Promise { - if (!ethers.isAddress(factoryAddress)) { - throw new TokenDeploymentError('Invalid factory address'); - } - - if (!ethers.isAddress(safeAddress)) { - throw new TokenDeploymentError('Invalid Safe address'); - } - - if (!salt) { - throw new TokenDeploymentError('Salt is required for deployment'); - } - - let parsedInput: TokenDeploymentParams; - - try { - parsedInput = await tokenDeploymentParamsSchema.parseAsync(JSON.parse(inputJson)); - logger.info('Successfully validated token deployment input', { - name: parsedInput.name, - symbol: parsedInput.symbol, - }); - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to parse or validate input JSON', { error, inputJson }); - throw new TokenDeploymentError(`Invalid input format: ${error.message}`); - } - throw error; - } - - try { - // For now, we'll use an empty array for remoteTokenPools - const remoteTokenPools = parsedInput.remoteTokenPools; - - // Get the factory interface - const tokenInterface = FactoryBurnMintERC20__factory.createInterface(); - - // Encode token constructor parameters using the factory interface - const constructorArgs = tokenInterface.encodeDeploy([ - parsedInput.name, - parsedInput.symbol, - parsedInput.decimals, - parsedInput.maxSupply, - parsedInput.preMint, - safeAddress, - ]); - - // Combine bytecode and constructor args using solidityPacked - const tokenInitCode = ethers.solidityPacked( - ['bytes', 'bytes'], - [BYTECODES.FACTORY_BURN_MINT_ERC20, constructorArgs], - ); - - // Get the factory interface - const factoryInterface = TokenPoolFactory__factory.createInterface(); - - // Get the appropriate pool bytecode based on pool type - const tokenPoolInitCode = BYTECODES.BURN_MINT_TOKEN_POOL; - - // Compute deterministic addresses - const tokenAddress = computeCreate2Address(factoryAddress, tokenInitCode, salt, safeAddress); - - logger.info('Computed Token deterministic addresses', { - tokenAddress, - salt, - }); - - // Encode the function call to deployTokenAndTokenPool - const data = factoryInterface.encodeFunctionData('deployTokenAndTokenPool', [ - remoteTokenPools, - parsedInput.decimals, - tokenInitCode, - tokenPoolInitCode, - salt, - ]); - - logger.info('Successfully generated token and pool deployment transaction'); - - return { - to: factoryAddress, - value: '0', - data, - operation: SafeOperationType.Call, - }; - } catch (error) { - if (error instanceof Error) { - logger.error('Failed to generate deployment transaction', { error }); - throw new TokenDeploymentError(`Failed to generate deployment transaction: ${error.message}`); - } - throw error; - } +export interface TokenDeploymentGenerator { + /** + * Generates a token and pool deployment transaction. + * + * @param inputJson - JSON string containing token deployment parameters + * @param factoryAddress - Address of the TokenPoolFactory contract + * @param salt - 32-byte salt for CREATE2 deterministic deployment (hex string with 0x prefix) + * @param safeAddress - Address of the Safe multisig (becomes token owner and admin) + * + * @returns Transaction data containing target address, encoded function call, and operation type + * + * @throws {TokenDeploymentError} When validation fails or transaction generation fails + * + * @see {@link tokenDeploymentParamsSchema} for input JSON schema + * @see {@link SafeTransactionDataBase} for return type structure + */ + generate( + inputJson: string, + factoryAddress: string, + salt: string, + safeAddress: string, + ): Promise; } /** - * Creates a Safe Transaction Builder JSON for the token deployment - * @param transaction - The Safe transaction data - * @param params - The token deployment parameters - * @param metadata - The Safe metadata - * @returns The Safe Transaction Builder JSON + * Creates a token and pool deployment generator. + * + * Factory function that creates a generator for deploying both BurnMintERC20 tokens + * and their associated TokenPool contracts via the TokenPoolFactory. Uses CREATE2 + * for deterministic address computation, allowing the token and pool addresses to be + * known before deployment. + * + * @param logger - Logger instance for operation logging and debugging + * @param interfaceProvider - Provider for contract ABI interfaces (TokenPoolFactory, BurnMintERC20) + * @param addressComputer - Computes CREATE2 deterministic addresses based on factory, init code, and salt + * + * @returns Generator instance implementing {@link TokenDeploymentGenerator} interface + * + * @remarks + * The generator follows this process: + * 1. Validates input JSON against Zod schema + * 2. Validates factory and Safe addresses + * 3. Encodes token constructor parameters (name, symbol, decimals, max supply, pre-mint, owner) + * 4. Combines token bytecode with constructor args to create init code + * 5. Computes CREATE2 token address for reference + * 6. Encodes factory `deployTokenAndTokenPool` function call + * 7. Returns transaction data ready for execution + * + * The factory contract will: + * - Deploy the token contract using CREATE2 + * - Deploy the pool contract using CREATE2 + * - Set up initial cross-chain configurations if provided + * - Transfer ownership to the Safe address + * + * @example + * ```typescript + * const generator = createTokenDeploymentGenerator( + * logger, + * interfaceProvider, + * addressComputer + * ); + * + * const inputJson = JSON.stringify({ + * name: "MyToken", + * symbol: "MTK", + * decimals: 18, + * maxSupply: "1000000000000000000000000", + * preMint: "100000000000000000000", + * remoteTokenPools: [] + * }); + * + * const transaction = await generator.generate( + * inputJson, + * "0x17d8a409fe2cef2d3808bcb61f14abeffc28876e", // factory + * "0x0000000000000000000000000000000000000000000000000000000123456789", // salt + * "0x5419c6d83473d1c653e7b51e8568fafedce94f01" // safe + * ); + * + * console.log(transaction.to); // Factory address + * console.log(transaction.data); // Encoded deployTokenAndTokenPool call + * console.log(transaction.value); // "0" (no ETH sent) + * console.log(transaction.operation); // SafeOperationType.Call + * ``` + * + * @throws {TokenDeploymentError} When factory address is invalid + * @throws {TokenDeploymentError} When Safe address is invalid + * @throws {TokenDeploymentError} When salt is missing or invalid + * @throws {TokenDeploymentError} When input JSON validation fails + * @throws {TokenDeploymentError} When transaction encoding fails + * + * @see {@link TokenDeploymentGenerator} for interface definition + * @see {@link tokenDeploymentParamsSchema} for input validation schema + * @see {@link IAddressComputer.computeCreate2Address} for address computation + * @see {@link BYTECODES} for contract bytecodes + * + * @public */ -export function createTokenDeploymentJSON( - transaction: SafeTransactionDataBase, - params: TokenDeploymentParams, - metadata: SafeMetadata, -): SafeTransactionBuilderJSON { - // Get the factory interface - const factoryInterface = TokenPoolFactory__factory.createInterface(); - - // Get the deployTokenAndTokenPool function fragment - const methodFragment = factoryInterface.getFunction('deployTokenAndTokenPool'); - +export function createTokenDeploymentGenerator( + logger: ILogger, + interfaceProvider: IInterfaceProvider, + addressComputer: IAddressComputer, +): TokenDeploymentGenerator { return { - version: '1.0', - chainId: metadata.chainId, - createdAt: Date.now(), - meta: { - name: `Token and Pool Factory Deployment - ${params.name}`, - description: `Deploy ${params.name} (${params.symbol}) token and associated pool using factory`, - txBuilderVersion: SAFE_TX_BUILDER_VERSION, - createdFromSafeAddress: metadata.safeAddress, - createdFromOwnerAddress: metadata.ownerAddress, + async generate( + inputJson: string, + factoryAddress: string, + salt: string, + safeAddress: string, + ): Promise { + if (!ethers.isAddress(factoryAddress)) { + throw new TokenDeploymentError('Invalid factory address'); + } + + if (!ethers.isAddress(safeAddress)) { + throw new TokenDeploymentError('Invalid Safe address'); + } + + if (!salt) { + throw new TokenDeploymentError('Salt is required for deployment'); + } + + // Parse and validate input + const parsedInput = await executeAsync( + async () => tokenDeploymentParamsSchema.parseAsync(JSON.parse(inputJson)), + TokenDeploymentError, + 'Invalid input format', + { inputJson }, + ); + + logger.info('Successfully validated token deployment input', { + name: parsedInput.name, + symbol: parsedInput.symbol, + }); + + try { + const remoteTokenPools = parsedInput.remoteTokenPools; + + // Get the token interface + const tokenInterface = interfaceProvider.getFactoryBurnMintERC20Interface(); + + // Encode token constructor parameters + const constructorArgs = tokenInterface.encodeDeploy([ + parsedInput.name, + parsedInput.symbol, + parsedInput.decimals, + parsedInput.maxSupply, + parsedInput.preMint, + safeAddress, + ]); + + // Combine bytecode and constructor args + const tokenInitCode = ethers.solidityPacked( + ['bytes', 'bytes'], + [BYTECODES.FACTORY_BURN_MINT_ERC20, constructorArgs], + ); + + // Get the factory interface + const factoryInterface = interfaceProvider.getTokenPoolFactoryInterface(); + + // Get the pool bytecode + const tokenPoolInitCode = BYTECODES.BURN_MINT_TOKEN_POOL; + + // Compute deterministic addresses + const tokenAddress = addressComputer.computeCreate2Address( + factoryAddress, + tokenInitCode, + salt, + safeAddress, + ); + + logger.info('Computed Token deterministic addresses', { + tokenAddress, + salt, + }); + + // Encode the function call + const data = factoryInterface.encodeFunctionData('deployTokenAndTokenPool', [ + remoteTokenPools, + parsedInput.decimals, + tokenInitCode, + tokenPoolInitCode, + salt, + ]); + + logger.info('Successfully generated token and pool deployment transaction'); + + return { + to: factoryAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }; + } catch (error) { + logError(error, 'generate deployment transaction', { + factoryAddress, + salt, + safeAddress, + }); + throw error instanceof Error + ? new TokenDeploymentError('Failed to generate deployment transaction', undefined, error) + : error; + } }, - transactions: [ - { - to: transaction.to, - value: transaction.value, - data: transaction.data, - operation: transaction.operation, - contractMethod: { - inputs: methodFragment.inputs.map((input) => ({ - name: input.name, - type: input.type, - internalType: input.type, - })), - name: methodFragment.name, - payable: methodFragment.payable, - }, - contractInputsValues: null, // The data field already contains the encoded function call - }, - ], }; } diff --git a/src/generators/tokenMint.ts b/src/generators/tokenMint.ts new file mode 100644 index 0000000..d837990 --- /dev/null +++ b/src/generators/tokenMint.ts @@ -0,0 +1,203 @@ +/** + * @fileoverview Token minting transaction generator for BurnMintERC20 tokens. + * + * This module generates transactions for minting tokens to specified receivers. + * Requires the caller to have the MINTER_ROLE on the BurnMintERC20 token contract. + * + * @module generators/tokenMint + */ + +import { ethers } from 'ethers'; +import { mintParamsSchema } from '../types/tokenMint'; +import { SafeOperationType, SafeTransactionDataBase } from '../types/safe'; +import { DEFAULTS } from '../config'; +import { TokenMintError } from '../errors'; +import { executeAsync, logError } from '../errors/AsyncErrorHandler'; +import { ILogger, IInterfaceProvider } from '../interfaces'; + +/** + * Generator interface for token minting transactions. + * + * Generates transactions for minting BurnMintERC20 tokens to specified receivers. + * The caller (Safe multisig or EOA) must have the MINTER_ROLE granted on the token + * contract for the transaction to succeed. + * + * @remarks + * The generator validates input parameters, encodes the mint function call with + * receiver and amount, and returns transaction data ready for execution. + * + * @public + */ +export interface TokenMintGenerator { + /** + * Generates a token minting transaction. + * + * @param inputJson - JSON string containing mint parameters (receiver address and amount) + * @param tokenAddress - Address of the BurnMintERC20 token contract + * + * @returns Transaction data containing target address, encoded mint call, and operation type + * + * @throws {TokenMintError} When validation fails or transaction generation fails + * + * @see {@link mintParamsSchema} for input JSON schema + * @see {@link SafeTransactionDataBase} for return type structure + */ + generate(inputJson: string, tokenAddress: string): Promise; +} + +/** + * Creates a token minting transaction generator. + * + * Factory function that creates a generator for minting BurnMintERC20 tokens. The minting + * operation requires the caller to have been granted the MINTER_ROLE on the token contract, + * typically done via the role management generator. + * + * @param logger - Logger instance for operation logging and debugging + * @param interfaceProvider - Provider for contract ABI interfaces (BurnMintERC20) + * + * @returns Generator instance implementing {@link TokenMintGenerator} interface + * + * @remarks + * The generator follows this process: + * 1. Validates token address format + * 2. Validates input JSON against Zod schema (receiver address, amount) + * 3. Encodes BurnMintERC20 `mint` function call + * 4. Returns transaction data ready for execution + * + * Minter Role Requirement: + * - The transaction will **fail** if the caller doesn't have MINTER_ROLE + * - Use the role management generator to grant MINTER_ROLE before minting + * - For token pools using BurnMintTokenPool, grant MINTER_ROLE to the pool contract + * + * Amount Format: + * - Amount is specified as a string to avoid JavaScript number precision issues + * - Represents token amount in smallest unit (e.g., wei for 18 decimal tokens) + * - Example: "1000000000000000000" = 1 token with 18 decimals + * + * @example + * ```typescript + * const generator = createTokenMintGenerator(logger, interfaceProvider); + * + * // Mint 1000 tokens (with 18 decimals) to receiver + * const inputJson = JSON.stringify({ + * receiver: "0x1234567890123456789012345678901234567890", + * amount: "1000000000000000000000" // 1000 * 10^18 + * }); + * + * const transaction = await generator.generate( + * inputJson, + * "0x779877A7B0D9E8603169DdbD7836e478b4624789" // token address + * ); + * + * console.log(transaction.to); // Token contract address + * console.log(transaction.data); // Encoded mint(receiver, amount) call + * console.log(transaction.value); // "0" (no ETH sent) + * console.log(transaction.operation); // SafeOperationType.Call + * + * // Execute via Safe multisig (which must have MINTER_ROLE) + * ``` + * + * @example + * ```typescript + * // Mint small amount for testing + * const inputJson = JSON.stringify({ + * receiver: "0xRecipientAddress", + * amount: "100000000" // 0.0000001 tokens (with 18 decimals) + * }); + * + * const transaction = await generator.generate(inputJson, tokenAddress); + * ``` + * + * @example + * ```typescript + * // Complete workflow: Grant role, then mint + * // Step 1: Grant MINTER_ROLE to Safe + * const roleGenerator = createRoleManagementGenerator(logger, interfaceProvider); + * const grantRoleTx = await roleGenerator.generate( + * JSON.stringify({ + * grantee: "0xSafeAddress", + * roleType: "mint", + * action: "grant" + * }), + * tokenAddress + * ); + * // Execute grantRoleTx... + * + * // Step 2: Now Safe can mint tokens + * const mintGenerator = createTokenMintGenerator(logger, interfaceProvider); + * const mintTx = await mintGenerator.generate( + * JSON.stringify({ + * receiver: "0xRecipient", + * amount: "1000000000000000000" + * }), + * tokenAddress + * ); + * // Execute mintTx via Safe + * ``` + * + * @throws {TokenMintError} When token address is invalid + * @throws {TokenMintError} When input JSON validation fails + * @throws {TokenMintError} When receiver address is invalid + * @throws {TokenMintError} When amount is invalid (negative, non-numeric) + * @throws {TokenMintError} When transaction encoding fails + * + * @see {@link TokenMintGenerator} for interface definition + * @see {@link mintParamsSchema} for input validation schema + * @see {@link createRoleManagementGenerator} for granting MINTER_ROLE + * + * @public + */ +export function createTokenMintGenerator( + logger: ILogger, + interfaceProvider: IInterfaceProvider, +): TokenMintGenerator { + return { + async generate(inputJson: string, tokenAddress: string): Promise { + if (!ethers.isAddress(tokenAddress)) { + throw new TokenMintError('Invalid token address'); + } + + // Parse and validate input + const parsedInput = await executeAsync( + async () => mintParamsSchema.parseAsync(JSON.parse(inputJson)), + TokenMintError, + 'Invalid input format', + { inputJson }, + ); + + logger.info('Successfully validated mint input', { + receiver: parsedInput.receiver, + amount: parsedInput.amount, + }); + + try { + // Get the token interface + const tokenInterface = interfaceProvider.getFactoryBurnMintERC20Interface(); + + // Encode the function call to mint + const data = tokenInterface.encodeFunctionData('mint', [ + parsedInput.receiver, + parsedInput.amount, + ]); + + logger.info('Successfully generated mint transaction', { + tokenAddress, + receiver: parsedInput.receiver, + amount: parsedInput.amount, + }); + + return { + to: tokenAddress, + value: DEFAULTS.TRANSACTION_VALUE, + data, + operation: SafeOperationType.Call, + }; + } catch (error) { + logError(error, 'generate mint transaction', { tokenAddress }); + throw error instanceof Error + ? new TokenMintError('Failed to generate mint transaction', undefined, error) + : error; + } + }, + }; +} diff --git a/src/interfaces/IAddressComputer.ts b/src/interfaces/IAddressComputer.ts new file mode 100644 index 0000000..f5bc255 --- /dev/null +++ b/src/interfaces/IAddressComputer.ts @@ -0,0 +1,103 @@ +/** + * @fileoverview Address computer interface for CREATE2 address prediction. + * + * Defines the contract for computing deterministic contract addresses using the + * CREATE2 opcode. Used by token deployment generator to predict deployed addresses + * before transactions are executed. + * + * @module interfaces/IAddressComputer + */ + +/** + * Interface for computing CREATE2 deterministic contract addresses. + * + * Provides deterministic address computation for contracts deployed via CREATE2, + * accounting for the TokenPoolFactory's salt modification strategy where the salt + * is hashed with msg.sender before deployment. + * + * @remarks + * CREATE2 Address Computation: + * 1. Factory modifies salt: `modifiedSalt = keccak256(abi.encodePacked(salt, msg.sender))` + * 2. Compute address: `keccak256(0xff ++ deployer ++ modifiedSalt ++ keccak256(initCode))` + * + * This allows different senders to use the same salt value and deploy to different + * addresses, enabling multi-tenancy without salt collision. + * + * @example + * ```typescript + * const addressComputer = createAddressComputer(logger); + * + * // Compute token address before deployment + * const predictedTokenAddress = addressComputer.computeCreate2Address( + * factoryAddress, + * tokenBytecode, + * salt, + * safeAddress // msg.sender + * ); + * + * // Generate deployment transaction + * const transaction = await generator.generate( + * inputJson, + * factoryAddress, + * salt, + * safeAddress + * ); + * + * // After execution, token will be at predictedTokenAddress + * ``` + * + * @see {@link createAddressComputer} for production implementation + * + * @public + */ +export interface IAddressComputer { + /** + * Computes the CREATE2 address for a contract deployment. + * + * Calculates the deterministic address where a contract will be deployed using + * CREATE2, accounting for the TokenPoolFactory's salt modification (hashing with + * msg.sender). + * + * @param deployer - Address of the deployer contract (TokenPoolFactory) + * @param bytecode - Complete contract bytecode including constructor arguments + * @param salt - Original 32-byte salt value (before factory modification) + * @param sender - Address of the transaction sender (msg.sender, typically Safe) + * + * @returns The computed CREATE2 address where the contract will be deployed + * + * @remarks + * Algorithm: + * 1. Compute modified salt: `keccak256(abi.encodePacked(salt, sender))` + * 2. Compute init code hash: `keccak256(bytecode)` + * 3. Compute address: `keccak256(0xff ++ deployer ++ modifiedSalt ++ initCodeHash)` + * 4. Take last 20 bytes and format as Ethereum address + * + * Used by token deployment generator to compute both token and pool addresses + * before deployment transactions are executed. + * + * @example + * ```typescript + * // Compute token address for deployment + * const tokenAddress = addressComputer.computeCreate2Address( + * "0x17d8a409fe2cef2d3808bcb61f14abeffc28876e", // Factory + * tokenBytecode, // Full bytecode + * "0x0000000000000000000000000000000000000000000000000000000123456789", + * "0x5419c6d83473d1c653e7b51e8568fafedce94f01" // Safe address + * ); + * // Returns: "0x779877A7B0D9E8603169DdbD7836e478b4624789" + * ``` + * + * @example + * ```typescript + * // Same salt, different sender = different address + * const address1 = addressComputer.computeCreate2Address( + * factoryAddress, bytecode, salt, "0xSender1" + * ); + * const address2 = addressComputer.computeCreate2Address( + * factoryAddress, bytecode, salt, "0xSender2" + * ); + * // address1 !== address2 (different senders, different addresses) + * ``` + */ + computeCreate2Address(deployer: string, bytecode: string, salt: string, sender: string): string; +} diff --git a/src/interfaces/IInterfaceProvider.ts b/src/interfaces/IInterfaceProvider.ts new file mode 100644 index 0000000..0898323 --- /dev/null +++ b/src/interfaces/IInterfaceProvider.ts @@ -0,0 +1,86 @@ +/** + * @fileoverview Interface provider contract for accessing contract ABIs. + * + * Defines the interface for accessing ethers.js contract interfaces from TypeChain + * factories. Implementations typically include caching to avoid repeated ABI parsing. + * + * @module interfaces/IInterfaceProvider + */ +import { ethers } from 'ethers'; + +/** + * Interface provider for accessing contract ABI interfaces. + * + * Provides access to ethers.js Interface instances for all contracts used in the + * application. Used by generators for encoding function calls and by formatters + * for extracting method fragments. + * + * @remarks + * Implementations should cache interface instances to avoid repeatedly parsing + * ABIs from TypeChain factories. See InterfaceProvider service for the production + * implementation with caching. + * + * Contract Interfaces: + * - **TokenPool**: Chain updates, allow list, rate limiter operations + * - **TokenPoolFactory**: Token and pool deployment operations + * - **FactoryBurnMintERC20**: Token minting and role management operations + * + * @example + * ```typescript + * // Usage in generator + * function createChainUpdateGenerator( + * logger: ILogger, + * interfaceProvider: IInterfaceProvider + * ): ChainUpdateGenerator { + * return { + * async generate(inputJson: string) { + * const poolInterface = interfaceProvider.getTokenPoolInterface(); + * const data = poolInterface.encodeFunctionData('applyChainUpdates', [...]); + * return { to: '', value: '0', data, operation: SafeOperationType.Call }; + * } + * }; + * } + * ``` + * + * @see {@link createInterfaceProvider} for production implementation + * + * @public + */ +export interface IInterfaceProvider { + /** + * Gets the TokenPool contract interface. + * + * @returns ethers.Interface for TokenPool contract + * + * @remarks + * Used by generators for: + * - applyChainUpdates (chain update generator) + * - applyAllowListUpdates (allow list generator) + * - setChainRateLimiterConfig (rate limiter generator) + */ + getTokenPoolInterface(): ethers.Interface; + + /** + * Gets the TokenPoolFactory contract interface. + * + * @returns ethers.Interface for TokenPoolFactory contract + * + * @remarks + * Used by generators for: + * - deployTokenAndTokenPool (token deployment generator) + * - deployTokenPoolWithExistingToken (pool deployment generator) + */ + getTokenPoolFactoryInterface(): ethers.Interface; + + /** + * Gets the FactoryBurnMintERC20 contract interface. + * + * @returns ethers.Interface for FactoryBurnMintERC20 contract + * + * @remarks + * Used by generators for: + * - mint (token mint generator) + * - grantMintAndBurn, revokeMintRole, revokeBurnRole (role management generator) + */ + getFactoryBurnMintERC20Interface(): ethers.Interface; +} diff --git a/src/interfaces/ILogger.ts b/src/interfaces/ILogger.ts new file mode 100644 index 0000000..1a62e03 --- /dev/null +++ b/src/interfaces/ILogger.ts @@ -0,0 +1,126 @@ +/** + * @fileoverview Logger interface for dependency injection and testing. + * + * Defines a standard logging contract used throughout the application. Enables + * dependency injection of logging implementations and facilitates testing with + * mock loggers. + * + * @module interfaces/ILogger + */ + +/** + * Logger interface for structured application logging. + * + * Provides standard logging levels (info, error, warn, debug) with support for + * structured metadata. Implementations can use Winston, console, or custom loggers. + * + * @remarks + * This interface enables: + * - Dependency injection of logging implementations + * - Easy testing with mock loggers + * - Consistent logging across all modules + * - Structured logging with metadata support + * + * The production implementation uses Winston (see utils/logger.ts). + * Tests typically use mock implementations or silent loggers. + * + * @example + * ```typescript + * // Production usage + * const logger = createLogger(); // Winston implementation + * logger.info('Token deployment successful', { + * tokenAddress: '0x123...', + * poolAddress: '0xabc...' + * }); + * ``` + * + * @example + * ```typescript + * // Testing usage + * const mockLogger: ILogger = { + * info: jest.fn(), + * error: jest.fn(), + * warn: jest.fn(), + * debug: jest.fn() + * }; + * + * const generator = createTokenDeploymentGenerator( + * mockLogger, // Inject mock + * interfaceProvider, + * addressComputer + * ); + * ``` + * + * @public + */ +export interface ILogger { + /** + * Logs an informational message with optional structured metadata. + * + * @param message - Human-readable log message + * @param meta - Optional structured metadata (object with key-value pairs) + * + * @example + * ```typescript + * logger.info('Successfully validated input', { + * chainSelector: '16015286601757825753', + * poolType: 'BurnMintTokenPool' + * }); + * ``` + */ + info(message: string, meta?: object): void; + + /** + * Logs an error message with optional structured metadata. + * + * @param message - Human-readable error description + * @param meta - Optional error details (stack trace, context, etc.) + * + * @example + * ```typescript + * logger.error('Transaction generation failed', { + * error: error.message, + * inputJson, + * factoryAddress + * }); + * ``` + */ + error(message: string, meta?: object): void; + + /** + * Logs a warning message with optional structured metadata. + * + * @param message - Human-readable warning description + * @param meta - Optional warning context + * + * @example + * ```typescript + * logger.warn('Using deprecated pool type', { + * poolType: 'LockReleaseTokenPool', + * suggested: 'BurnMintTokenPool' + * }); + * ``` + */ + warn(message: string, meta?: object): void; + + /** + * Logs a debug message with optional structured metadata. + * + * @param message - Human-readable debug information + * @param meta - Optional debug context + * + * @remarks + * Debug logs are typically disabled in production and used during development + * or troubleshooting to trace execution flow. + * + * @example + * ```typescript + * logger.debug('Computing CREATE2 address', { + * salt: '0x000...123', + * deployer: factoryAddress, + * initCodeHash: '0xabc...' + * }); + * ``` + */ + debug(message: string, meta?: object): void; +} diff --git a/src/interfaces/IOutputWriterFactory.ts b/src/interfaces/IOutputWriterFactory.ts new file mode 100644 index 0000000..6e4a797 --- /dev/null +++ b/src/interfaces/IOutputWriterFactory.ts @@ -0,0 +1,33 @@ +/** + * Output writer factory interface + */ + +/** + * Output destination type + */ +export type OutputDestination = { type: 'console' } | { type: 'file'; path: string }; + +/** + * Output writer interface + */ +export interface IOutputWriter { + /** + * Write content to destination + */ + write(content: string, destination: OutputDestination): Promise; +} + +/** + * Factory for creating output writers + */ +export interface IOutputWriterFactory { + /** + * Create a calldata writer + */ + createCalldataWriter(): IOutputWriter; + + /** + * Create a JSON writer + */ + createJsonWriter(): IOutputWriter; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts new file mode 100644 index 0000000..d9b7fa3 --- /dev/null +++ b/src/interfaces/index.ts @@ -0,0 +1,13 @@ +/** + * @fileoverview Core interfaces for dependency injection and abstraction. + * + * This module exports all interface definitions used throughout the application + * for dependency injection, testing, and loose coupling between components. + * + * @module interfaces + */ + +export { ILogger } from './ILogger'; +export { IInterfaceProvider } from './IInterfaceProvider'; +export { IAddressComputer } from './IAddressComputer'; +export { IOutputWriterFactory, IOutputWriter, OutputDestination } from './IOutputWriterFactory'; diff --git a/src/output/OutputService.ts b/src/output/OutputService.ts new file mode 100644 index 0000000..c4c7c66 --- /dev/null +++ b/src/output/OutputService.ts @@ -0,0 +1,288 @@ +/** + * @fileoverview High-level service for CLI output operations. + * + * This module provides the OutputService class which orchestrates output writing + * for CLI commands. Handles format selection (calldata vs Safe JSON), writer + * instantiation, and destination resolution. + * + * The service acts as a facade over the OutputWriter abstraction, providing + * convenience methods for common output scenarios. + * + * @module output/OutputService + */ + +import { OUTPUT_FORMAT } from '../config'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON } from '../types/safe'; +import { OutputDestination, OutputWriterFactory } from './OutputWriter'; + +/** + * Output format type union. + * + * Defines the supported output formats for CLI commands. + * + * @public + */ +export type OutputFormat = typeof OUTPUT_FORMAT.CALLDATA | typeof OUTPUT_FORMAT.SAFE_JSON; + +/** + * Service for handling CLI output operations. + * + * Provides high-level methods for writing transaction data in different formats. + * Orchestrates writer selection, format conversion, and output destination. + * + * @remarks + * Responsibilities: + * - Format selection (calldata vs Safe JSON) + * - Writer instantiation via factory + * - Destination resolution (console vs file) + * - Multiple transaction handling + * - Error validation for format requirements + * + * Usage Pattern: + * 1. Instantiate service: `const service = new OutputService()` + * 2. Call appropriate method: `await service.writeCalldata(tx, path)` + * 3. Or use generic write: `await service.write(format, tx, safeJson, path)` + * + * @example + * ```typescript + * const service = new OutputService(); + * const transaction = { + * to: '0x...', + * value: '0', + * data: '0x1234...', + * operation: SafeOperationType.Call + * }; + * + * // Write calldata to console + * await service.writeCalldata(transaction); + * + * // Write calldata to file + * await service.writeCalldata(transaction, 'output/calldata.txt'); + * + * // Write Safe JSON + * const safeJson = { ... }; // SafeTransactionBuilderJSON + * await service.writeSafeJson(safeJson, 'output/safe.json'); + * ``` + * + * @public + */ +export class OutputService { + /** + * Write transaction data as raw calldata. + * + * Extracts calldata from transaction(s) and writes to console or file. + * Supports both single transactions and arrays of multiple transactions. + * + * @param transaction - Transaction or array of transactions + * @param outputPath - Optional file path (if not provided, writes to console) + * @returns Promise that resolves when write is complete + * + * @remarks + * Multiple Transactions: + * - If array provided, extracts calldata from each transaction + * - Joins multiple calldata with newlines + * - Each calldata on separate line in output + * + * Single Transaction: + * - Extracts transaction.data field + * - Writes single hex string + * + * Destination: + * - outputPath provided: Write to file at path + * - outputPath undefined: Write to console (stdout) + * + * @example + * ```typescript + * const service = new OutputService(); + * const tx = { + * to: '0x1234...', + * value: '0', + * data: '0xabcdef...', + * operation: SafeOperationType.Call + * }; + * + * // Write to console + * await service.writeCalldata(tx); + * // Output: 0xabcdef... + * + * // Write to file + * await service.writeCalldata(tx, 'output/calldata.txt'); + * // File contains: 0xabcdef...\n + * ``` + * + * @example + * ```typescript + * // Multiple transactions + * const transactions = [ + * { to: '0x...', value: '0', data: '0x1111...', operation: 0 }, + * { to: '0x...', value: '0', data: '0x2222...', operation: 0 } + * ]; + * + * await service.writeCalldata(transactions, 'output/batch.txt'); + * // File contains: + * // 0x1111... + * // 0x2222... + * ``` + */ + async writeCalldata( + transaction: SafeTransactionDataBase | SafeTransactionDataBase[], + outputPath?: string, + ): Promise { + const writer = OutputWriterFactory.createCalldataWriter(); + const destination: OutputDestination = outputPath + ? { type: 'file', path: outputPath } + : { type: 'console' }; + + // Handle multiple transactions + const calldata = Array.isArray(transaction) + ? transaction.map((tx) => tx.data).join('\n') + : transaction.data; + + await writer.write(calldata, destination); + } + + /** + * Write Safe Transaction Builder JSON. + * + * Writes formatted Safe Transaction Builder JSON to console or file. + * Uses Prettier for consistent JSON formatting. + * + * @param safeJson - Complete Safe Transaction Builder JSON structure + * @param outputPath - Optional file path (if not provided, writes to console) + * @returns Promise that resolves when write is complete + * + * @remarks + * JSON Formatting: + * - Serializes to JSON string + * - Formats with Prettier using project config + * - Writes formatted JSON + * + * Destination: + * - outputPath provided: Write to file at path + * - outputPath undefined: Write to console (stdout) + * + * Use Case: + * - Import into Safe Transaction Builder UI + * - Human-readable transaction review + * - Version control friendly format + * + * @example + * ```typescript + * const service = new OutputService(); + * const safeJson: SafeTransactionBuilderJSON = { + * version: '1.18.0', + * chainId: '84532', + * createdAt: Date.now(), + * meta: { + * name: 'Deploy Token', + * description: 'Deploy BurnMintERC20 token', + * txBuilderVersion: '1.18.0', + * createdFromSafeAddress: '0xSafe', + * createdFromOwnerAddress: '0xOwner' + * }, + * transactions: [...] + * }; + * + * // Write to console (formatted) + * await service.writeSafeJson(safeJson); + * + * // Write to file + * await service.writeSafeJson(safeJson, 'output/safe.json'); + * ``` + */ + async writeSafeJson(safeJson: SafeTransactionBuilderJSON, outputPath?: string): Promise { + const writer = OutputWriterFactory.createJsonWriter(); + const destination: OutputDestination = outputPath + ? { type: 'file', path: outputPath } + : { type: 'console' }; + + await writer.write(JSON.stringify(safeJson), destination); + } + + /** + * Write output based on format selection. + * + * Convenience method that automatically selects the appropriate write method + * based on the output format. Handles format validation and routing. + * + * @param format - Output format ('calldata' or 'safe-json') + * @param transaction - Transaction or array of transactions + * @param safeJson - Safe JSON (required if format is 'safe-json') + * @param outputPath - Optional file path + * @returns Promise that resolves when write is complete + * @throws {Error} If format is 'safe-json' but safeJson is null + * + * @remarks + * Format Routing: + * - 'safe-json': Calls writeSafeJson() with safeJson parameter + * - 'calldata': Calls writeCalldata() with transaction parameter + * + * Validation: + * - If format is 'safe-json', safeJson must be provided (not null) + * - Throws error if Safe JSON format requested but safeJson is null + * + * Use Case: + * - Generic output handling when format is determined at runtime + * - CLI handlers that support both output formats + * + * @example + * ```typescript + * const service = new OutputService(); + * const transaction = { ... }; + * const safeJson = { ... }; + * + * // Write calldata + * await service.write( + * OUTPUT_FORMAT.CALLDATA, + * transaction, + * null, + * 'output/calldata.txt' + * ); + * + * // Write Safe JSON + * await service.write( + * OUTPUT_FORMAT.SAFE_JSON, + * transaction, + * safeJson, + * 'output/safe.json' + * ); + * ``` + * + * @example + * ```typescript + * // Error case - Safe JSON format but no safeJson provided + * try { + * await service.write( + * OUTPUT_FORMAT.SAFE_JSON, + * transaction, + * null, // Missing safeJson + * 'output.json' + * ); + * } catch (error) { + * // Error: Safe JSON format requested but no Safe JSON provided + * } + * ``` + * + * @example + * ```typescript + * // Format determined at runtime (CLI) + * const format = options.format || OUTPUT_FORMAT.CALLDATA; + * await service.write(format, transaction, safeJson, options.output); + * ``` + */ + async write( + format: OutputFormat, + transaction: SafeTransactionDataBase | SafeTransactionDataBase[], + safeJson: SafeTransactionBuilderJSON | null, + outputPath?: string, + ): Promise { + if (format === OUTPUT_FORMAT.SAFE_JSON) { + if (!safeJson) { + throw new Error('Safe JSON format requested but no Safe JSON provided'); + } + await this.writeSafeJson(safeJson, outputPath); + } else { + await this.writeCalldata(transaction, outputPath); + } + } +} diff --git a/src/output/OutputWriter.ts b/src/output/OutputWriter.ts new file mode 100644 index 0000000..1347d5f --- /dev/null +++ b/src/output/OutputWriter.ts @@ -0,0 +1,374 @@ +/** + * @fileoverview Output writer abstraction for CLI command results. + * + * This module provides an abstraction layer for writing CLI output to either console + * (stdout) or files. Uses the Strategy pattern with abstract OutputWriter base class + * and concrete implementations for different output formats. + * + * Features: + * - Calldata writer for raw hex strings + * - JSON writer with Prettier formatting + * - Automatic file path resolution and directory creation + * - Consistent logging of file writes + * + * @module output/OutputWriter + */ + +import fs from 'fs/promises'; +import path from 'path'; +import prettier from 'prettier'; +import logger from '../utils/logger'; + +/** + * Output destination discriminated union. + * + * Specifies where output should be written: console (stdout) or file system. + * + * @remarks + * Discriminated union enables type-safe handling of console vs file output: + * - Console: No additional data needed + * - File: Requires file path + * + * @example + * ```typescript + * // Write to console + * const consoleDestination: OutputDestination = { type: 'console' }; + * + * // Write to file + * const fileDestination: OutputDestination = { + * type: 'file', + * path: 'output/transaction.json' + * }; + * ``` + * + * @public + */ +export type OutputDestination = { type: 'console' } | { type: 'file'; path: string }; + +/** + * Abstract base class for output writers. + * + * Defines the contract for output writers using the Strategy pattern. Concrete + * implementations handle specific output formats (calldata, JSON) with appropriate + * formatting. + * + * @remarks + * Strategy Pattern: + * - Abstract write() method implemented by subclasses + * - Optional format() hook for content formatting + * - Shared writeToConsole() and writeToFile() utilities + * + * Subclasses: + * - {@link CalldataWriter} for raw hex calldata + * - {@link JsonWriter} for formatted JSON + * + * @example + * ```typescript + * // Implementing a custom writer + * class CustomWriter extends OutputWriter { + * async write(content: string, destination: OutputDestination): Promise { + * const formatted = await this.format(content); + * if (destination.type === 'console') { + * this.writeToConsole(formatted); + * } else { + * await this.writeToFile(formatted, destination.path); + * } + * } + * + * protected format(content: string): string { + * return content.toUpperCase(); // Custom formatting + * } + * } + * ``` + * + * @public + */ +export abstract class OutputWriter { + /** + * Write output to the specified destination. + * + * Abstract method that must be implemented by subclasses to handle + * format-specific output writing. + * + * @param content - The content to write (may be unformatted) + * @param destination - Where to write the output (console or file) + * @returns Promise that resolves when write is complete + * + * @remarks + * Implementations typically: + * 1. Format content using format() method + * 2. Check destination type + * 3. Write to console or file using protected methods + */ + abstract write(content: string, destination: OutputDestination): Promise; + + /** + * Format content before writing. + * + * Optional hook for subclasses to transform content before output. + * Default implementation returns content unchanged. + * + * @param content - The content to format + * @returns Formatted content (synchronous or asynchronous) + * + * @remarks + * Can return string or Promise for flexibility: + * - CalldataWriter returns string (synchronous) + * - JsonWriter returns Promise (async Prettier formatting) + * + * @protected + */ + protected format(content: string): string | Promise { + return content; + } + + /** + * Write content to console (stdout). + * + * Utility method for writing to console with consistent behavior. + * Uses console.log for output. + * + * @param content - The formatted content to write + * + * @protected + */ + protected writeToConsole(content: string): void { + console.log(content); + } + + /** + * Write content to file system. + * + * Utility method for writing to files with path resolution and logging. + * + * @param content - The formatted content to write + * @param filePath - File path (relative or absolute) + * @returns Promise that resolves when file is written + * + * @remarks + * Features: + * - Resolves relative paths to absolute paths + * - Logs successful writes with resolved path + * - Creates parent directories if needed (Node.js fs.writeFile behavior) + * + * @example + * ```typescript + * // Relative path + * await this.writeToFile(content, 'output/result.json'); + * // Resolves to: /path/to/project/output/result.json + * + * // Absolute path + * await this.writeToFile(content, '/tmp/result.json'); + * // Writes to: /tmp/result.json + * ``` + * + * @protected + */ + protected async writeToFile(content: string, filePath: string): Promise { + const resolvedPath = path.resolve(filePath); + await fs.writeFile(resolvedPath, content); + logger.info('Successfully wrote output to file', { outputPath: resolvedPath }); + } +} + +/** + * Calldata output writer for raw hex strings. + * + * Writes transaction calldata (hex-encoded function calls) to console or file. + * Ensures proper newline formatting for file output. + * + * @remarks + * Use Cases: + * - Raw calldata for web3 libraries + * - Block explorer transaction construction + * - Foundry cast send commands + * - Manual transaction building + * + * Formatting: + * - Adds trailing newline if missing (better file output) + * - No other formatting applied (preserves hex string) + * + * @example + * ```typescript + * const writer = new CalldataWriter(); + * const calldata = '0x1234567890abcdef...'; + * + * // Write to console + * await writer.write(calldata, { type: 'console' }); + * // Output: 0x1234567890abcdef... + * + * // Write to file + * await writer.write(calldata, { type: 'file', path: 'output/calldata.txt' }); + * // File: 0x1234567890abcdef...\n + * ``` + * + * @public + */ +export class CalldataWriter extends OutputWriter { + /** + * Write calldata to console or file. + * + * @param content - Raw hex calldata string + * @param destination - Where to write the output + */ + async write(content: string, destination: OutputDestination): Promise { + const formatted = await Promise.resolve(this.format(content)); + + if (destination.type === 'console') { + this.writeToConsole(formatted); + } else { + await this.writeToFile(formatted, destination.path); + } + } + + /** + * Format calldata by ensuring trailing newline. + * + * @param content - Raw hex calldata string + * @returns Formatted calldata with trailing newline + * + * @protected + */ + protected format(content: string): string { + // Ensure newline at end for file output + return content.endsWith('\n') ? content : content + '\n'; + } +} + +/** + * JSON output writer with Prettier formatting. + * + * Writes Safe Transaction Builder JSON with automatic formatting using Prettier. + * Respects project's Prettier configuration. + * + * @remarks + * Use Cases: + * - Safe Transaction Builder JSON files + * - Formatted JSON for human review + * - JSON output with consistent style + * + * Formatting: + * - Parses JSON to validate structure + * - Formats using Prettier with project config + * - Uses 'json' parser for proper formatting + * + * @example + * ```typescript + * const writer = new JsonWriter(); + * const json = '{"version":"1.18.0","chainId":"1","transactions":[]}'; + * + * // Write formatted JSON to console + * await writer.write(json, { type: 'console' }); + * // Output: + * // { + * // "version": "1.18.0", + * // "chainId": "1", + * // "transactions": [] + * // } + * + * // Write to file + * await writer.write(json, { type: 'file', path: 'output/safe.json' }); + * ``` + * + * @example + * ```typescript + * // Handles malformed JSON + * try { + * await writer.write('invalid json', { type: 'console' }); + * } catch (error) { + * // JSON.parse error thrown during format() + * } + * ``` + * + * @public + */ +export class JsonWriter extends OutputWriter { + /** + * Write formatted JSON to console or file. + * + * @param content - JSON string (will be parsed and formatted) + * @param destination - Where to write the output + */ + async write(content: string, destination: OutputDestination): Promise { + const formatted = await this.format(content); + + if (destination.type === 'console') { + this.writeToConsole(formatted); + } else { + await this.writeToFile(formatted, destination.path); + } + } + + /** + * Format JSON using Prettier. + * + * Parses JSON to validate, then formats with Prettier using project config. + * + * @param content - JSON string to format + * @returns Formatted JSON string + * @throws {SyntaxError} If JSON is malformed + * + * @remarks + * Formatting steps: + * 1. Parse JSON to validate and get object + * 2. Resolve Prettier config from project + * 3. Stringify object and format with Prettier + * 4. Return formatted JSON string + * + * @protected + */ + protected async format(content: string): Promise { + // Parse and re-format with prettier + const obj: unknown = JSON.parse(content); + const config = await prettier.resolveConfig(process.cwd()); + return prettier.format(JSON.stringify(obj), { + ...config, + parser: 'json', + }); + } +} + +/** + * Factory for creating output writers. + * + * Provides factory methods for instantiating appropriate writer types. + * Encapsulates writer construction logic. + * + * @remarks + * Factory Pattern Benefits: + * - Centralized writer creation + * - Easy to extend with new writer types + * - Consistent instantiation + * + * @example + * ```typescript + * // Create writers using factory + * const calldataWriter = OutputWriterFactory.createCalldataWriter(); + * const jsonWriter = OutputWriterFactory.createJsonWriter(); + * + * // Use writers + * await calldataWriter.write('0x1234...', { type: 'console' }); + * await jsonWriter.write('{"key":"value"}', { type: 'console' }); + * ``` + * + * @public + */ +export class OutputWriterFactory { + /** + * Create a calldata writer instance. + * + * @returns New CalldataWriter instance + */ + static createCalldataWriter(): CalldataWriter { + return new CalldataWriter(); + } + + /** + * Create a JSON writer instance. + * + * @returns New JsonWriter instance + */ + static createJsonWriter(): JsonWriter { + return new JsonWriter(); + } +} diff --git a/src/output/index.ts b/src/output/index.ts new file mode 100644 index 0000000..239bc24 --- /dev/null +++ b/src/output/index.ts @@ -0,0 +1,21 @@ +/** + * @fileoverview Output module barrel export. + * + * This module re-exports all output writing functionality for CLI commands. + * Provides access to both low-level output writers (Strategy pattern) and + * high-level output service (Facade pattern). + * + * Exported Components: + * - {@link OutputWriter} - Abstract base class for output writers + * - {@link CalldataWriter} - Writer for raw hex calldata + * - {@link JsonWriter} - Writer for formatted Safe JSON + * - {@link OutputWriterFactory} - Factory for creating writers + * - {@link OutputService} - High-level service for CLI output operations + * - {@link OutputDestination} - Discriminated union for output destination (console/file) + * - {@link OutputFormat} - Output format type ('calldata' or 'safe-json') + * + * @module output + */ + +export * from './OutputWriter'; +export * from './OutputService'; diff --git a/src/services/InterfaceProvider.ts b/src/services/InterfaceProvider.ts new file mode 100644 index 0000000..650bca2 --- /dev/null +++ b/src/services/InterfaceProvider.ts @@ -0,0 +1,129 @@ +/** + * @fileoverview Interface provider service for cached contract ABI access. + * + * This module provides a caching layer for ethers.js contract interfaces, avoiding + * the overhead of repeatedly parsing ABIs from TypeChain-generated factories. All + * generators and formatters use this service to access contract method fragments + * for function encoding and Safe JSON generation. + * + * @module services/InterfaceProvider + */ + +import { ethers } from 'ethers'; +import { + TokenPool__factory, + TokenPoolFactory__factory, + FactoryBurnMintERC20__factory, +} from '../typechain'; +import { IInterfaceProvider } from '../interfaces'; + +/** + * Creates an interface provider with caching for contract ABIs. + * + * Factory function that returns an IInterfaceProvider implementation with internal + * caching. Each contract interface is created once on first access and reused for + * all subsequent calls, improving performance when encoding multiple transactions. + * + * @returns IInterfaceProvider instance with cached interface access + * + * @remarks + * The provider implements lazy initialization with caching: + * - First call to getXInterface(): Creates and caches the interface + * - Subsequent calls: Returns cached interface instantly + * + * Cached Interfaces: + * - **TokenPool**: For chain updates, allow list, rate limiter operations + * - **TokenPoolFactory**: For token and pool deployment operations + * - **FactoryBurnMintERC20**: For minting and role management operations + * + * Benefits: + * - Avoids repeated ABI parsing overhead + * - Consistent interface instances across all generators + * - Memory efficient (one instance per interface type) + * - Simplifies generator and formatter implementations + * + * The provider is used by all generators for encoding function calls and by + * all formatters for extracting method fragments for Safe JSON. + * + * @example + * ```typescript + * const provider = createInterfaceProvider(); + * + * // First call parses ABI and caches + * const tokenPoolInterface = provider.getTokenPoolInterface(); + * const data = tokenPoolInterface.encodeFunctionData('applyChainUpdates', [...]); + * + * // Second call returns cached instance (fast) + * const sameInterface = provider.getTokenPoolInterface(); + * const data2 = sameInterface.encodeFunctionData('setChainRateLimiterConfig', [...]); + * ``` + * + * @example + * ```typescript + * // Usage in generator + * function createChainUpdateGenerator( + * logger: ILogger, + * interfaceProvider: IInterfaceProvider // Injected provider + * ): ChainUpdateGenerator { + * return { + * async generate(inputJson: string) { + * const poolInterface = interfaceProvider.getTokenPoolInterface(); + * const data = poolInterface.encodeFunctionData('applyChainUpdates', [ + * parsedInput.removes, + * parsedInput.adds + * ]); + * return { to: '', value: '0', data, operation: SafeOperationType.Call }; + * } + * }; + * } + * ``` + * + * @example + * ```typescript + * // Usage in formatter + * function createMintFormatter( + * interfaceProvider: IInterfaceProvider // Injected provider + * ): MintFormatter { + * return { + * format(transaction, params, metadata) { + * const contractInterface = interfaceProvider.getFactoryBurnMintERC20Interface(); + * const methodFragment = contractInterface.getFunction('mint'); + * // Use methodFragment for Safe JSON generation... + * } + * }; + * } + * ``` + * + * @see {@link IInterfaceProvider} for interface definition + * @see {@link TokenPool__factory} for TokenPool TypeChain factory + * @see {@link TokenPoolFactory__factory} for TokenPoolFactory TypeChain factory + * @see {@link FactoryBurnMintERC20__factory} for FactoryBurnMintERC20 TypeChain factory + * + * @public + */ +export function createInterfaceProvider(): IInterfaceProvider { + const cache = new Map(); + + return { + getTokenPoolInterface(): ethers.Interface { + if (!cache.has('tokenPool')) { + cache.set('tokenPool', TokenPool__factory.createInterface()); + } + return cache.get('tokenPool')!; + }, + + getTokenPoolFactoryInterface(): ethers.Interface { + if (!cache.has('tokenPoolFactory')) { + cache.set('tokenPoolFactory', TokenPoolFactory__factory.createInterface()); + } + return cache.get('tokenPoolFactory')!; + }, + + getFactoryBurnMintERC20Interface(): ethers.Interface { + if (!cache.has('factoryBurnMintERC20')) { + cache.set('factoryBurnMintERC20', FactoryBurnMintERC20__factory.createInterface()); + } + return cache.get('factoryBurnMintERC20')!; + }, + }; +} diff --git a/src/services/ServiceContainer.ts b/src/services/ServiceContainer.ts new file mode 100644 index 0000000..f0ffbcc --- /dev/null +++ b/src/services/ServiceContainer.ts @@ -0,0 +1,322 @@ +/** + * @fileoverview Dependency injection container for application services. + * + * This module implements a centralized service container that creates, wires, and manages + * all application dependencies including loggers, generators, formatters, and services. + * Provides singleton pattern for global access and testability support. + * + * @module services/ServiceContainer + */ + +import { ILogger, IInterfaceProvider, IAddressComputer } from '../interfaces'; +import { createLogger } from '../utils/logger'; +import { createInterfaceProvider } from './InterfaceProvider'; +import { createAddressComputer } from '../utils/addressComputer'; + +// Generator factories +import { + createChainUpdateGenerator, + ChainUpdateGenerator, +} from '../generators/chainUpdateCalldata'; +import { + createTokenDeploymentGenerator, + TokenDeploymentGenerator, +} from '../generators/tokenDeployment'; +import { + createPoolDeploymentGenerator, + PoolDeploymentGenerator, +} from '../generators/poolDeployment'; +import { createTokenMintGenerator, TokenMintGenerator } from '../generators/tokenMint'; +import { + createRoleManagementGenerator, + RoleManagementGenerator, +} from '../generators/roleManagement'; +import { + createAllowListUpdatesGenerator, + AllowListUpdatesGenerator, +} from '../generators/allowListUpdates'; +import { + createRateLimiterConfigGenerator, + RateLimiterConfigGenerator, +} from '../generators/rateLimiterConfig'; +import { + createAcceptOwnershipGenerator, + AcceptOwnershipGenerator, +} from '../generators/acceptOwnership'; + +// Formatter factories +import { + createChainUpdateFormatter, + ChainUpdateFormatter, +} from '../formatters/chainUpdateFormatter'; +import { + createTokenDeploymentFormatter, + TokenDeploymentFormatter, +} from '../formatters/tokenDeploymentFormatter'; +import { + createPoolDeploymentFormatter, + PoolDeploymentFormatter, +} from '../formatters/poolDeploymentFormatter'; +import { createMintFormatter, MintFormatter } from '../formatters/mintFormatter'; +import { + createRoleManagementFormatter, + RoleManagementFormatter, +} from '../formatters/roleManagementFormatter'; +import { createAllowListFormatter, AllowListFormatter } from '../formatters/allowListFormatter'; +import { + createRateLimiterFormatter, + RateLimiterFormatter, +} from '../formatters/rateLimiterFormatter'; +import { + createAcceptOwnershipFormatter, + AcceptOwnershipFormatter, +} from '../formatters/acceptOwnershipFormatter'; + +// Services +import { + createTransactionService, + TransactionService, + TransactionServiceDependencies, +} from './TransactionService'; + +/** + * Service container interface holding all application services and dependencies. + * + * Central registry providing access to all generators, formatters, and services + * needed by the application. Implements dependency injection pattern for testability + * and modularity. + * + * @remarks + * The container is organized into four tiers: + * 1. **Core Dependencies**: Logger, interface provider, address computer + * 2. **Generators**: Raw transaction data generators (7 types) + * 3. **Formatters**: Safe JSON formatters (7 types) + * 4. **Services**: High-level services (TransactionService) + * + * Benefits: + * - Single source of truth for all dependencies + * - Consistent initialization order + * - Easy mocking for tests + * - Singleton access via getServiceContainer() + * + * @public + */ +export interface ServiceContainer { + // Core dependencies + logger: ILogger; + interfaceProvider: IInterfaceProvider; + addressComputer: IAddressComputer; + + // Generators + chainUpdateGenerator: ChainUpdateGenerator; + tokenDeploymentGenerator: TokenDeploymentGenerator; + poolDeploymentGenerator: PoolDeploymentGenerator; + tokenMintGenerator: TokenMintGenerator; + roleManagementGenerator: RoleManagementGenerator; + allowListUpdatesGenerator: AllowListUpdatesGenerator; + rateLimiterConfigGenerator: RateLimiterConfigGenerator; + acceptOwnershipGenerator: AcceptOwnershipGenerator; + + // Formatters + chainUpdateFormatter: ChainUpdateFormatter; + tokenDeploymentFormatter: TokenDeploymentFormatter; + poolDeploymentFormatter: PoolDeploymentFormatter; + mintFormatter: MintFormatter; + roleManagementFormatter: RoleManagementFormatter; + allowListFormatter: AllowListFormatter; + rateLimiterFormatter: RateLimiterFormatter; + acceptOwnershipFormatter: AcceptOwnershipFormatter; + + // Services + transactionService: TransactionService; +} + +/** + * Creates a fully configured service container with all dependencies wired. + * + * Factory function that instantiates and connects all application components + * in the correct dependency order: core utilities → generators → formatters → services. + * + * @returns Fully configured ServiceContainer with all dependencies initialized + * + * @remarks + * Initialization follows a 4-step dependency graph: + * 1. Core dependencies (logger, interface provider, address computer) + * 2. Generators (depend on logger, interface provider, address computer) + * 3. Formatters (depend on interface provider) + * 4. Services (depend on generators and formatters) + * + * This function is called by getServiceContainer() to create the singleton instance. + * Can also be used directly for testing with custom dependencies. + * + * @example + * ```typescript + * // Normal usage (via singleton) + * const container = getServiceContainer(); + * const { transactionService } = container; + * + * // Testing usage (fresh instance) + * const testContainer = createServiceContainer(); + * // Manipulate for testing... + * ``` + * + * @see {@link getServiceContainer} for singleton access + * @see {@link ServiceContainer} for container interface + * + * @public + */ +export function createServiceContainer(): ServiceContainer { + // Step 1: Create core dependencies + const logger = createLogger(); + const interfaceProvider = createInterfaceProvider(); + const addressComputer = createAddressComputer(logger); + + // Step 2: Create generators + const chainUpdateGenerator = createChainUpdateGenerator(logger, interfaceProvider); + const tokenDeploymentGenerator = createTokenDeploymentGenerator( + logger, + interfaceProvider, + addressComputer, + ); + const poolDeploymentGenerator = createPoolDeploymentGenerator(logger, interfaceProvider); + const tokenMintGenerator = createTokenMintGenerator(logger, interfaceProvider); + const roleManagementGenerator = createRoleManagementGenerator(logger, interfaceProvider); + const allowListUpdatesGenerator = createAllowListUpdatesGenerator(logger, interfaceProvider); + const rateLimiterConfigGenerator = createRateLimiterConfigGenerator(logger, interfaceProvider); + const acceptOwnershipGenerator = createAcceptOwnershipGenerator(logger); + + // Step 3: Create formatters + const chainUpdateFormatter = createChainUpdateFormatter(interfaceProvider); + const tokenDeploymentFormatter = createTokenDeploymentFormatter(interfaceProvider); + const poolDeploymentFormatter = createPoolDeploymentFormatter(interfaceProvider); + const mintFormatter = createMintFormatter(interfaceProvider); + const roleManagementFormatter = createRoleManagementFormatter(interfaceProvider); + const allowListFormatter = createAllowListFormatter(interfaceProvider); + const rateLimiterFormatter = createRateLimiterFormatter(interfaceProvider); + const acceptOwnershipFormatter = createAcceptOwnershipFormatter(); + + // Step 4: Create TransactionService with all dependencies + const transactionServiceDeps: TransactionServiceDependencies = { + chainUpdateGenerator, + tokenDeploymentGenerator, + poolDeploymentGenerator, + tokenMintGenerator, + roleManagementGenerator, + allowListUpdatesGenerator, + rateLimiterConfigGenerator, + acceptOwnershipGenerator, + chainUpdateFormatter, + tokenDeploymentFormatter, + poolDeploymentFormatter, + mintFormatter, + roleManagementFormatter, + allowListFormatter, + rateLimiterFormatter, + acceptOwnershipFormatter, + }; + const transactionService = createTransactionService(transactionServiceDeps); + + // Return the complete container + return { + // Core dependencies + logger, + interfaceProvider, + addressComputer, + + // Generators + chainUpdateGenerator, + tokenDeploymentGenerator, + poolDeploymentGenerator, + tokenMintGenerator, + roleManagementGenerator, + allowListUpdatesGenerator, + rateLimiterConfigGenerator, + acceptOwnershipGenerator, + + // Formatters + chainUpdateFormatter, + tokenDeploymentFormatter, + poolDeploymentFormatter, + mintFormatter, + roleManagementFormatter, + allowListFormatter, + rateLimiterFormatter, + acceptOwnershipFormatter, + + // Services + transactionService, + }; +} + +/** + * Singleton container instance + * Created lazily on first access + */ +let containerInstance: ServiceContainer | null = null; + +/** + * Gets the singleton service container instance. + * + * Returns the global service container, creating it on first access. Subsequent + * calls return the cached instance, ensuring all parts of the application share + * the same dependencies. + * + * @returns The singleton ServiceContainer instance + * + * @remarks + * Implements lazy initialization: + * - First call: Creates and caches the container + * - Subsequent calls: Returns cached instance + * + * This is the recommended way to access services in production code. + * For testing, use resetServiceContainer() to clear the singleton. + * + * @example + * ```typescript + * // CLI usage + * const container = getServiceContainer(); + * const result = await container.transactionService.generateTokenDeployment( + * inputJson, + * factoryAddress, + * salt, + * safeAddress, + * metadata + * ); + * ``` + * + * @see {@link createServiceContainer} for container factory + * @see {@link resetServiceContainer} for testing reset + * + * @public + */ +export function getServiceContainer(): ServiceContainer { + if (!containerInstance) { + containerInstance = createServiceContainer(); + } + return containerInstance; +} + +/** + * Resets the singleton service container (for testing). + * + * Clears the cached container instance, forcing the next call to + * getServiceContainer() to create a fresh instance. Should only be + * used in test teardown. + * + * @remarks + * This function is intended for test isolation to ensure each test + * gets a fresh container without shared state from previous tests. + * + * @example + * ```typescript + * // Test teardown + * afterEach(() => { + * resetServiceContainer(); + * }); + * ``` + * + * @internal + */ +export function resetServiceContainer(): void { + containerInstance = null; +} diff --git a/src/services/TransactionService.ts b/src/services/TransactionService.ts new file mode 100644 index 0000000..6a46f80 --- /dev/null +++ b/src/services/TransactionService.ts @@ -0,0 +1,492 @@ +/** + * @fileoverview Transaction service for coordinating transaction generation and formatting. + * + * This module provides a unified service interface that coordinates all transaction generators + * and formatters in the system. It acts as a facade pattern, simplifying transaction generation + * by automatically handling both raw transaction data generation and optional Safe Transaction + * Builder JSON formatting in a single method call. + * + * @module services/TransactionService + */ + +import { ChainUpdateGenerator } from '../generators/chainUpdateCalldata'; +import { TokenDeploymentGenerator } from '../generators/tokenDeployment'; +import { PoolDeploymentGenerator } from '../generators/poolDeployment'; +import { TokenMintGenerator } from '../generators/tokenMint'; +import { RoleManagementGenerator } from '../generators/roleManagement'; +import { AllowListUpdatesGenerator } from '../generators/allowListUpdates'; +import { RateLimiterConfigGenerator } from '../generators/rateLimiterConfig'; +import { AcceptOwnershipGenerator } from '../generators/acceptOwnership'; +import { ChainUpdateFormatter } from '../formatters/chainUpdateFormatter'; +import { TokenDeploymentFormatter } from '../formatters/tokenDeploymentFormatter'; +import { PoolDeploymentFormatter } from '../formatters/poolDeploymentFormatter'; +import { MintFormatter } from '../formatters/mintFormatter'; +import { RoleManagementFormatter } from '../formatters/roleManagementFormatter'; +import { AllowListFormatter } from '../formatters/allowListFormatter'; +import { RateLimiterFormatter } from '../formatters/rateLimiterFormatter'; +import { + AcceptOwnershipFormatter, + SafeAcceptOwnershipMetadata, +} from '../formatters/acceptOwnershipFormatter'; +import { SafeTransactionDataBase, SafeTransactionBuilderJSON, SafeMetadata } from '../types/safe'; +import { SafeChainUpdateMetadata } from '../types/chainUpdate'; +import { SafeMintMetadata, SafeRoleManagementMetadata } from '../types/tokenMint'; +import { SafeAllowListMetadata } from '../types/allowList'; +import { SafeRateLimiterMetadata } from '../types/rateLimiter'; +import { + parseJSON, + isTokenDeploymentParams, + isPoolDeploymentParams, + isMintParams, + isRoleManagementParams, + isAllowListUpdatesInput, + isSetChainRateLimiterConfigInput, +} from '../types/typeGuards'; + +/** + * Dependencies required for TransactionService instantiation. + * + * Contains all generator and formatter instances needed by the service. + * This dependency injection pattern enables testability and modularity. + * + * @remarks + * The service requires: + * - 7 generator instances (for different transaction types) + * - 7 corresponding formatter instances (for Safe JSON formatting) + * + * Generators and formatters are injected rather than created internally to: + * - Enable easy testing with mocks + * - Allow custom implementations + * - Maintain single responsibility (service doesn't manage dependencies) + * - Support different configuration strategies + * + * @public + */ +export interface TransactionServiceDependencies { + // Generators + chainUpdateGenerator: ChainUpdateGenerator; + tokenDeploymentGenerator: TokenDeploymentGenerator; + poolDeploymentGenerator: PoolDeploymentGenerator; + tokenMintGenerator: TokenMintGenerator; + roleManagementGenerator: RoleManagementGenerator; + allowListUpdatesGenerator: AllowListUpdatesGenerator; + rateLimiterConfigGenerator: RateLimiterConfigGenerator; + acceptOwnershipGenerator: AcceptOwnershipGenerator; + // Formatters + chainUpdateFormatter: ChainUpdateFormatter; + tokenDeploymentFormatter: TokenDeploymentFormatter; + poolDeploymentFormatter: PoolDeploymentFormatter; + mintFormatter: MintFormatter; + roleManagementFormatter: RoleManagementFormatter; + allowListFormatter: AllowListFormatter; + rateLimiterFormatter: RateLimiterFormatter; + acceptOwnershipFormatter: AcceptOwnershipFormatter; +} + +/** + * Transaction service for coordinating transaction generation and Safe JSON formatting. + * + * Provides a unified interface for generating all types of token pool transactions. + * Each method generates raw transaction data and optionally formats it into Safe + * Transaction Builder JSON format based on whether metadata is provided. + * + * @remarks + * The service implements a facade pattern that simplifies transaction generation by: + * 1. Accepting input parameters and optional Safe metadata + * 2. Calling the appropriate generator to create raw transaction data + * 3. Optionally calling the corresponding formatter to create Safe JSON + * 4. Returning both raw transaction and Safe JSON (if metadata provided) + * + * Transaction Types Supported: + * - **Chain Updates**: Add/remove cross-chain configurations + * - **Token Deployment**: Deploy token and pool via factory + * - **Pool Deployment**: Deploy pool only for existing token + * - **Token Minting**: Mint tokens (requires MINTER_ROLE) + * - **Role Management**: Grant/revoke mint and burn roles + * - **Allow List**: Add/remove addresses from access control + * - **Rate Limiter**: Configure per-chain transfer rate limits + * + * Usage Pattern: + * - Provide metadata parameter → get both raw transaction and Safe JSON + * - Omit metadata parameter → get only raw transaction data + * + * Benefits: + * - Single method call for generation + formatting + * - Consistent API across all transaction types + * - Automatic type validation via type guards + * - Simplified CLI and programmatic usage + * + * @example + * ```typescript + * // Create service with all dependencies + * const service = createTransactionService({ + * chainUpdateGenerator, + * tokenDeploymentGenerator, + * poolDeploymentGenerator, + * tokenMintGenerator, + * roleManagementGenerator, + * allowListUpdatesGenerator, + * rateLimiterConfigGenerator, + * chainUpdateFormatter, + * tokenDeploymentFormatter, + * poolDeploymentFormatter, + * mintFormatter, + * roleManagementFormatter, + * allowListFormatter, + * rateLimiterFormatter + * }); + * + * // Generate token deployment with Safe JSON + * const { transaction, safeJson } = await service.generateTokenDeployment( + * inputJson, + * factoryAddress, + * salt, + * safeAddress, + * { + * chainId: "84532", + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner" + * } + * ); + * + * // Use raw transaction for direct execution + * console.log(transaction.to); // Factory address + * console.log(transaction.data); // Encoded function call + * + * // Use Safe JSON for multisig execution + * if (safeJson) { + * fs.writeFileSync('deployment.json', JSON.stringify(safeJson, null, 2)); + * } + * ``` + * + * @example + * ```typescript + * // Generate without Safe JSON (direct execution) + * const { transaction } = await service.generateMint( + * JSON.stringify({ receiver: "0xRecipient", amount: "1000" }), + * tokenAddress + * // No metadata = no Safe JSON generated + * ); + * + * // Execute directly with ethers.js + * const tx = await wallet.sendTransaction({ + * to: transaction.to, + * data: transaction.data, + * value: transaction.value + * }); + * ``` + * + * @public + */ +export class TransactionService { + constructor(private deps: TransactionServiceDependencies) {} + + /** + * Generates chain update transaction with optional Safe JSON formatting. + * + * Creates a transaction for adding or removing remote chain configurations on a TokenPool. + * Supports both EVM and SVM chain addresses with proper encoding. + * + * @param inputJson - JSON string containing chain updates (removes and adds arrays) + * @param tokenPoolAddress - Address of the TokenPool contract to update + * @param metadata - Optional Safe metadata (if provided, Safe JSON will be generated) + * + * @returns Object containing raw transaction and optional Safe JSON + * + * @remarks + * The generator leaves `transaction.to` empty; the formatter fills it with `tokenPoolAddress`. + * This allows the same generated transaction to be used with different pools. + * + * @example + * ```typescript + * // Generate with Safe JSON + * const { transaction, safeJson } = await service.generateChainUpdate( + * JSON.stringify([ + * [], // No chains to remove + * [{ // Add Ethereum Sepolia + * remoteChainSelector: "16015286601757825753", + * remoteChainType: "evm", + * remotePoolAddresses: ["0x1234..."], + * remoteTokenAddress: "0xabcd...", + * outboundRateLimiterConfig: { isEnabled: true, capacity: "1000", rate: "100" }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: "1000", rate: "100" } + * }] + * ]), + * "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * { + * chainId: "84532", + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner" + * } + * ); + * ``` + * + * @see {@link ChainUpdateGenerator} for raw transaction generation + * @see {@link ChainUpdateFormatter} for Safe JSON formatting + */ + async generateChainUpdate( + inputJson: string, + tokenPoolAddress: string, + metadata?: SafeChainUpdateMetadata, + ): Promise<{ + transaction: SafeTransactionDataBase; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const transaction = await this.deps.chainUpdateGenerator.generate(inputJson); + const safeJson = metadata + ? this.deps.chainUpdateFormatter.format(transaction, { ...metadata, tokenPoolAddress }) + : null; + + return { transaction, safeJson }; + } + + /** + * Generate token and pool deployment transaction and optional Safe JSON + */ + async generateTokenDeployment( + inputJson: string, + factoryAddress: string, + salt: string, + safeAddress: string, + metadata?: SafeMetadata, + ): Promise<{ + transaction: SafeTransactionDataBase; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const transaction = await this.deps.tokenDeploymentGenerator.generate( + inputJson, + factoryAddress, + salt, + safeAddress, + ); + const safeJson = metadata + ? this.deps.tokenDeploymentFormatter.format( + transaction, + parseJSON(inputJson, isTokenDeploymentParams), + metadata, + ) + : null; + + return { transaction, safeJson }; + } + + /** + * Generate pool deployment transaction and optional Safe JSON + */ + async generatePoolDeployment( + inputJson: string, + factoryAddress: string, + salt: string, + metadata?: SafeMetadata, + ): Promise<{ + transaction: SafeTransactionDataBase; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const transaction = await this.deps.poolDeploymentGenerator.generate( + inputJson, + factoryAddress, + salt, + ); + const safeJson = metadata + ? this.deps.poolDeploymentFormatter.format( + transaction, + parseJSON(inputJson, isPoolDeploymentParams), + metadata, + ) + : null; + + return { transaction, safeJson }; + } + + /** + * Generate mint transaction and optional Safe JSON + */ + async generateMint( + inputJson: string, + tokenAddress: string, + metadata?: SafeMintMetadata, + ): Promise<{ + transaction: SafeTransactionDataBase; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const transaction = await this.deps.tokenMintGenerator.generate(inputJson, tokenAddress); + const safeJson = metadata + ? this.deps.mintFormatter.format(transaction, parseJSON(inputJson, isMintParams), metadata) + : null; + + return { transaction, safeJson }; + } + + /** + * Generate role management transaction(s) and optional Safe JSON + */ + async generateRoleManagement( + inputJson: string, + tokenAddress: string, + metadata?: SafeRoleManagementMetadata, + ): Promise<{ + transactions: SafeTransactionDataBase[]; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const result = await this.deps.roleManagementGenerator.generate(inputJson, tokenAddress); + const safeJson = metadata + ? this.deps.roleManagementFormatter.format( + result, + parseJSON(inputJson, isRoleManagementParams), + metadata, + ) + : null; + + return { transactions: result.transactions, safeJson }; + } + + /** + * Generate allow list updates transaction and optional Safe JSON + */ + async generateAllowListUpdates( + inputJson: string, + tokenPoolAddress: string, + metadata?: SafeAllowListMetadata, + ): Promise<{ + transaction: SafeTransactionDataBase; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const transaction = await this.deps.allowListUpdatesGenerator.generate( + inputJson, + tokenPoolAddress, + ); + const safeJson = metadata + ? this.deps.allowListFormatter.format( + transaction, + parseJSON(inputJson, isAllowListUpdatesInput), + metadata, + ) + : null; + + return { transaction, safeJson }; + } + + /** + * Generate rate limiter config transaction and optional Safe JSON + */ + async generateRateLimiterConfig( + inputJson: string, + tokenPoolAddress: string, + metadata?: SafeRateLimiterMetadata, + ): Promise<{ + transaction: SafeTransactionDataBase; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const transaction = await this.deps.rateLimiterConfigGenerator.generate( + inputJson, + tokenPoolAddress, + ); + const safeJson = metadata + ? this.deps.rateLimiterFormatter.format( + transaction, + parseJSON(inputJson, isSetChainRateLimiterConfigInput), + metadata, + ) + : null; + + return { transaction, safeJson }; + } + + /** + * Generates accept ownership transaction with optional Safe JSON formatting. + * + * Creates a transaction for accepting ownership of a contract using the + * two-step ownership transfer pattern. Works with any Chainlink contract + * that implements this pattern (tokens, pools, etc.). + * + * @param contractAddress - Address of the contract to accept ownership of + * @param metadata - Optional Safe metadata (if provided, Safe JSON will be generated) + * + * @returns Object containing raw transaction and optional Safe JSON + * + * @remarks + * Two-step ownership pattern: + * 1. Current owner calls `transferOwnership(newOwner)` - sets `pendingOwner` + * 2. New owner calls `acceptOwnership()` - becomes the actual `owner` + * + * Common use case: After TokenPoolFactory deployment, Safe is set as pendingOwner + * on both token and pool. Safe must accept ownership before calling owner-only + * functions like `applyChainUpdates`. + * + * @example + * ```typescript + * // Accept ownership of a pool + * const { transaction, safeJson } = await service.generateAcceptOwnership( + * "0xPoolAddress...", + * { + * chainId: "84532", + * safeAddress: "0xYourSafe", + * ownerAddress: "0xYourOwner", + * contractAddress: "0xPoolAddress..." + * } + * ); + * ``` + * + * @see {@link AcceptOwnershipGenerator} for raw transaction generation + * @see {@link AcceptOwnershipFormatter} for Safe JSON formatting + */ + async generateAcceptOwnership( + contractAddress: string, + metadata?: SafeAcceptOwnershipMetadata, + ): Promise<{ + transaction: SafeTransactionDataBase; + safeJson: SafeTransactionBuilderJSON | null; + }> { + const transaction = await this.deps.acceptOwnershipGenerator.generate(contractAddress); + + const safeJson = metadata + ? this.deps.acceptOwnershipFormatter.format(transaction, metadata) + : null; + + return { transaction, safeJson }; + } +} + +/** + * Creates a TransactionService instance with all required dependencies. + * + * Factory function that constructs a TransactionService with the complete set of + * generators and formatters needed for all transaction types. + * + * @param deps - All required generator and formatter instances + * + * @returns Configured TransactionService instance ready for use + * + * @remarks + * This factory is typically used in conjunction with the ServiceContainer + * which manages creation and wiring of all dependencies. + * + * @example + * ```typescript + * // Manual service creation (testing, custom setups) + * const service = createTransactionService({ + * chainUpdateGenerator: createChainUpdateGenerator(logger, interfaceProvider), + * tokenDeploymentGenerator: createTokenDeploymentGenerator(logger, interfaceProvider, addressComputer), + * poolDeploymentGenerator: createPoolDeploymentGenerator(logger, interfaceProvider), + * tokenMintGenerator: createTokenMintGenerator(logger, interfaceProvider), + * roleManagementGenerator: createRoleManagementGenerator(logger, interfaceProvider), + * allowListUpdatesGenerator: createAllowListUpdatesGenerator(logger, interfaceProvider), + * rateLimiterConfigGenerator: createRateLimiterConfigGenerator(logger, interfaceProvider), + * chainUpdateFormatter: createChainUpdateFormatter(interfaceProvider), + * tokenDeploymentFormatter: createTokenDeploymentFormatter(interfaceProvider), + * poolDeploymentFormatter: createPoolDeploymentFormatter(interfaceProvider), + * mintFormatter: createMintFormatter(interfaceProvider), + * roleManagementFormatter: createRoleManagementFormatter(interfaceProvider), + * allowListFormatter: createAllowListFormatter(interfaceProvider), + * rateLimiterFormatter: createRateLimiterFormatter(interfaceProvider) + * }); + * ``` + * + * @see {@link TransactionService} for service implementation + * @see {@link TransactionServiceDependencies} for required dependencies + * + * @public + */ +export function createTransactionService(deps: TransactionServiceDependencies): TransactionService { + return new TransactionService(deps); +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..9aab019 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,16 @@ +/** + * Services module exports + */ + +export { + TransactionService, + createTransactionService, + type TransactionServiceDependencies, +} from './TransactionService'; +export { createInterfaceProvider } from './InterfaceProvider'; +export { + createServiceContainer, + getServiceContainer, + resetServiceContainer, + type ServiceContainer, +} from './ServiceContainer'; diff --git a/src/test/cli/errorHandler.test.ts b/src/test/cli/errorHandler.test.ts new file mode 100644 index 0000000..d909ddd --- /dev/null +++ b/src/test/cli/errorHandler.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for CLI error handling utilities. + * Covers error formatting, ZodError extraction, and user-facing messages. + */ + +import { z, ZodError } from 'zod'; + +import { CLIError, formatZodError, handleCLIError } from '../../cli/errorHandler'; +import logger from '../../utils/logger'; + +// Mock logger +jest.mock('../../utils/logger', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +// Mock process.exit to prevent test from exiting +const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); +}); + +// Mock console.error to capture output +const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); + +describe('errorHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('formatZodError', () => { + it('should format single field error', () => { + const schema = z.object({ + name: z.string(), + }); + + let zodError: ZodError | undefined; + try { + schema.parse({ name: 123 }); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + expect(zodError).toBeDefined(); + const formatted = formatZodError(zodError!); + expect(formatted).toContain('name:'); + expect(formatted).toContain('string'); + }); + + it('should format nested path errors', () => { + const schema = z.object({ + config: z.object({ + remoteChainType: z.string(), + }), + }); + + let zodError: ZodError | undefined; + try { + schema.parse({ config: {} }); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + expect(zodError).toBeDefined(); + const formatted = formatZodError(zodError!); + expect(formatted).toContain('config.remoteChainType:'); + }); + + it('should format array index paths', () => { + const schema = z.tuple([z.array(z.string()), z.array(z.object({ type: z.string() }))]); + + let zodError: ZodError | undefined; + try { + schema.parse([[], [{ notType: 'value' }]]); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + expect(zodError).toBeDefined(); + const formatted = formatZodError(zodError!); + expect(formatted).toContain('[1][0].type:'); + }); + + it('should format multiple errors', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + }); + + let zodError: ZodError | undefined; + try { + schema.parse({}); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + expect(zodError).toBeDefined(); + const formatted = formatZodError(zodError!); + expect(formatted).toContain('name:'); + expect(formatted).toContain('age:'); + expect(formatted).toContain(', '); + }); + + it('should handle empty path (root-level error)', () => { + const schema = z.string(); + + let zodError: ZodError | undefined; + try { + schema.parse(123); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + expect(zodError).toBeDefined(); + const formatted = formatZodError(zodError!); + // Root-level error should not have a field path prefix (no "fieldName: message" pattern) + // The message may contain colons in the Zod error text itself + expect(formatted).not.toMatch(/^[a-zA-Z_]+:/); + expect(formatted.toLowerCase()).toContain('string'); + }); + + it('should format deeply nested paths correctly', () => { + const schema = z.object({ + level1: z.object({ + level2: z.array( + z.object({ + level3: z.string(), + }), + ), + }), + }); + + let zodError: ZodError | undefined; + try { + schema.parse({ level1: { level2: [{ level3: 123 }] } }); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + expect(zodError).toBeDefined(); + const formatted = formatZodError(zodError!); + expect(formatted).toContain('level1.level2[0].level3:'); + }); + }); + + describe('handleCLIError', () => { + it('should display ZodError details from cause', () => { + const schema = z.object({ remoteChainType: z.string() }); + + let zodError: ZodError | undefined; + try { + schema.parse({}); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + const wrappedError = new Error('Invalid input format'); + (wrappedError as Error & { cause: Error }).cause = zodError!; + + expect(() => handleCLIError(wrappedError, 'generate-chain-update')).toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith('\nError in generate-chain-update:'); + expect(mockConsoleError).toHaveBeenCalledWith('Invalid input format'); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Details:')); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('remoteChainType:')); + }); + + it('should display generic cause message for non-Zod errors', () => { + const originalError = new Error('Connection timeout'); + const wrappedError = new Error('Failed to fetch data'); + (wrappedError as Error & { cause: Error }).cause = originalError; + + expect(() => handleCLIError(wrappedError, 'fetch-data')).toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith('\nError in fetch-data:'); + expect(mockConsoleError).toHaveBeenCalledWith('Failed to fetch data'); + expect(mockConsoleError).toHaveBeenCalledWith('Caused by: Connection timeout'); + }); + + it('should handle CLIError with technical details', () => { + const cliError = new CLIError('Invalid address format', { + providedAddress: '0xinvalid', + expectedFormat: '0x + 40 hex characters', + }); + + expect(() => handleCLIError(cliError, 'validate-address')).toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith('\nError in validate-address:'); + expect(mockConsoleError).toHaveBeenCalledWith('Invalid address format'); + // Technical details should be logged, not displayed + expect(logger.error).toHaveBeenCalledWith('validate-address failed', { + error: 'Invalid address format', + technicalDetails: { + providedAddress: '0xinvalid', + expectedFormat: '0x + 40 hex characters', + }, + }); + }); + + it('should handle CLIError without technical details', () => { + const cliError = new CLIError('Simple error message'); + + expect(() => handleCLIError(cliError, 'simple-command')).toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith('\nError in simple-command:'); + expect(mockConsoleError).toHaveBeenCalledWith('Simple error message'); + expect(logger.error).toHaveBeenCalledWith('simple-command failed', { + error: 'Simple error message', + }); + }); + + it('should handle Error without cause', () => { + const error = new Error('Something went wrong'); + + expect(() => handleCLIError(error, 'some-command')).toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith('\nError in some-command:'); + expect(mockConsoleError).toHaveBeenCalledWith('Something went wrong'); + // Should NOT display any "Caused by" or "Details" line + expect(mockConsoleError).not.toHaveBeenCalledWith(expect.stringContaining('Caused by:')); + expect(mockConsoleError).not.toHaveBeenCalledWith(expect.stringContaining('Details:')); + }); + + it('should handle unknown error type', () => { + const unknownError = 'string error thrown'; + + expect(() => handleCLIError(unknownError, 'unknown-command')).toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith('\nUnknown error in unknown-command'); + expect(logger.error).toHaveBeenCalledWith('unknown-command failed with unknown error', { + error: 'string error thrown', + }); + }); + + it('should log error with stack trace for standard Error', () => { + const error = new Error('Test error'); + + expect(() => handleCLIError(error, 'test-command')).toThrow('process.exit called'); + + expect(logger.error).toHaveBeenCalledWith( + 'test-command failed', + expect.objectContaining({ + error: 'Test error', + stack: expect.stringContaining('Error: Test error') as unknown, + }), + ); + }); + + it('should call process.exit with code 1', () => { + const error = new Error('Any error'); + + expect(() => handleCLIError(error, 'any-command')).toThrow('process.exit called'); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should display nested ZodError from chain update validation', () => { + // Simulate the exact error structure from chain update validation + const chainUpdateSchema = z.tuple([ + z.array(z.string()), + z.array( + z.object({ + remoteChainSelector: z.string(), + remotePoolAddresses: z.array(z.string()), + remoteTokenAddress: z.string(), + remoteChainType: z.enum(['evm', 'svm', 'mvm']), + }), + ), + ]); + + let zodError: ZodError | undefined; + try { + // Missing remoteChainType - exactly what the user experienced + chainUpdateSchema.parse([ + [], + [ + { + remoteChainSelector: '16015286601757825753', + remotePoolAddresses: ['0x992Aa783128AbaBFd522C27cde4B9A4bD457785e'], + remoteTokenAddress: '0x885e0E447CdB950798f81bB33B669A640BcB08F2', + }, + ], + ]); + } catch (error) { + if (error instanceof ZodError) { + zodError = error; + } + } + + const wrappedError = new Error('Invalid input format'); + (wrappedError as Error & { cause: Error }).cause = zodError!; + + expect(() => handleCLIError(wrappedError, 'generate-chain-update')).toThrow( + 'process.exit called', + ); + + // The user should see exactly what field is missing + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('[1][0].remoteChainType'), + ); + }); + }); +}); diff --git a/src/test/errors/AsyncErrorHandler.test.ts b/src/test/errors/AsyncErrorHandler.test.ts new file mode 100644 index 0000000..8a1f1a7 --- /dev/null +++ b/src/test/errors/AsyncErrorHandler.test.ts @@ -0,0 +1,404 @@ +/** + * Tests for AsyncErrorHandler + * Covers async error handling utilities and retry logic + */ + +import { + wrapAsync, + executeAsync, + wrapError, + logError, + retryAsync, + Result, +} from '../../errors/AsyncErrorHandler'; +import { BaseError, ErrorContext } from '../../errors/BaseError'; +import logger from '../../utils/logger'; + +// Mock logger +jest.mock('../../utils/logger', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +// Mock setTimeout to speed up tests +jest.mock('timers/promises', () => ({ + setTimeout: jest.fn((_delay: number) => Promise.resolve()), +})); + +class TestError extends BaseError { + constructor(message: string, context?: ErrorContext, cause?: Error) { + super(message, 'TEST_ERROR', context, cause); + } +} + +describe('AsyncErrorHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('wrapAsync', () => { + it('should return success result when operation succeeds', async () => { + const operation = async (): Promise => 'success data'; + const result = await wrapAsync(operation, 'Test operation'); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('success data'); + } + }); + + it('should return failure result when operation throws Error', async () => { + const operation = async (): Promise => { + throw new Error('Operation failed'); + }; + const result = await wrapAsync(operation, 'Test operation'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(Error); + expect(result.error.message).toContain('Test operation'); + expect(result.error.message).toContain('Operation failed'); + } + }); + + it('should wrap non-Error values', async () => { + const operation = async (): Promise => { + throw 'string error'; + }; + const result = await wrapAsync(operation, 'Test operation'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe('Test operation'); + } + }); + + it('should include context in error', async () => { + const operation = async (): Promise => { + throw new Error('Failed'); + }; + const context = { userId: '123', action: 'test' }; + const result = await wrapAsync(operation, 'Test operation', context); + + expect(result.success).toBe(false); + }); + + it('should return correct type for successful operations', async () => { + const operation = async (): Promise<{ foo: string; count: number }> => ({ + foo: 'bar', + count: 42, + }); + const result: Result<{ foo: string; count: number }> = await wrapAsync( + operation, + 'Test operation', + ); + + if (result.success) { + expect(result.data.foo).toBe('bar'); + expect(result.data.count).toBe(42); + } + }); + + it('should handle async operations that return promises', async () => { + const operation = async (): Promise => Promise.resolve('async result'); + const result = await wrapAsync(operation, 'Test operation'); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('async result'); + } + }); + }); + + describe('executeAsync', () => { + it('should return result when operation succeeds', async () => { + const operation = async (): Promise => 'success'; + const result = await executeAsync(operation, TestError, 'Test operation'); + + expect(result).toBe('success'); + }); + + it('should throw custom error when operation fails with Error', async () => { + const operation = async (): Promise => { + throw new Error('Original error'); + }; + + await expect(executeAsync(operation, TestError, 'Test operation')).rejects.toThrow(TestError); + await expect(executeAsync(operation, TestError, 'Test operation')).rejects.toThrow( + 'Test operation', + ); + }); + + it('should throw custom error when operation fails with non-Error', async () => { + const operation = async (): Promise => { + throw 'string error'; + }; + + await expect(executeAsync(operation, TestError, 'Test operation')).rejects.toThrow(TestError); + }); + + it('should include context in thrown error', async () => { + const operation = async (): Promise => { + throw new Error('Failed'); + }; + const context = { key: 'value' }; + + try { + await executeAsync(operation, TestError, 'Test operation', context); + fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(TestError); + if (error instanceof TestError) { + expect(error.context).toEqual(context); + } + } + }); + + it('should preserve original error as cause', async () => { + const originalError = new Error('Original'); + const operation = async (): Promise => { + throw originalError; + }; + + try { + await executeAsync(operation, TestError, 'Test operation'); + fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(TestError); + if (error instanceof TestError) { + expect(error.cause).toBe(originalError); + } + } + }); + }); + + describe('wrapError', () => { + it('should wrap Error with enhanced message', () => { + const originalError = new Error('Original message'); + const wrapped = wrapError(originalError, 'Operation failed'); + + expect(wrapped).toBeInstanceOf(Error); + expect(wrapped.message).toBe('Operation failed: Original message'); + expect(wrapped.cause).toBe(originalError); + }); + + it('should preserve stack trace from original Error', () => { + const originalError = new Error('Original'); + const originalStack = originalError.stack; + const wrapped = wrapError(originalError, 'Wrapped'); + + expect(wrapped.stack).toBe(originalStack); + }); + + it('should handle Error without stack', () => { + const originalError = new Error('Original'); + delete originalError.stack; + const wrapped = wrapError(originalError, 'Wrapped'); + + expect(wrapped.message).toBe('Wrapped: Original'); + }); + + it('should create new Error for string', () => { + const wrapped = wrapError('string error', 'Operation failed'); + + expect(wrapped).toBeInstanceOf(Error); + expect(wrapped.message).toBe('Operation failed'); + }); + + it('should create new Error for number', () => { + const wrapped = wrapError(42, 'Operation failed'); + + expect(wrapped).toBeInstanceOf(Error); + expect(wrapped.message).toBe('Operation failed'); + }); + + it('should create new Error for object', () => { + const wrapped = wrapError({ error: 'details' }, 'Operation failed'); + + expect(wrapped).toBeInstanceOf(Error); + expect(wrapped.message).toBe('Operation failed'); + }); + + it('should log non-Error values with context', () => { + const context = { key: 'value' }; + wrapError('string error', 'Operation failed', context); + + expect(logger.error).toHaveBeenCalledWith('Non-Error value caught', { + error: 'string error', + context, + }); + }); + + it('should not log when wrapping Error', () => { + const originalError = new Error('Original'); + wrapError(originalError, 'Wrapped'); + + expect(logger.error).not.toHaveBeenCalled(); + }); + }); + + describe('logError', () => { + it('should log BaseError with toJSON', () => { + const error = new TestError('Test error', { userId: '123' }); + logError(error, 'test operation', { additional: 'context' }); + + expect(logger.error).toHaveBeenCalledWith('test operation failed', { + ...error.toJSON(), + additionalContext: { additional: 'context' }, + }); + }); + + it('should log regular Error', () => { + const error = new Error('Regular error'); + logError(error, 'test operation', { key: 'value' }); + + expect(logger.error).toHaveBeenCalledWith('test operation failed', { + name: error.name, + message: error.message, + stack: error.stack, + context: { key: 'value' }, + }); + }); + + it('should log unknown error type', () => { + const error = 'string error'; + logError(error, 'test operation', { key: 'value' }); + + expect(logger.error).toHaveBeenCalledWith('test operation failed with unknown error', { + error: 'string error', + context: { key: 'value' }, + }); + }); + + it('should handle error without context', () => { + const error = new Error('Test'); + logError(error, 'test operation'); + + expect(logger.error).toHaveBeenCalledWith('test operation failed', { + name: error.name, + message: error.message, + stack: error.stack, + context: undefined, + }); + }); + + it('should log BaseError without additional context', () => { + const error = new TestError('Test error'); + logError(error, 'test operation'); + + expect(logger.error).toHaveBeenCalledWith('test operation failed', { + ...error.toJSON(), + additionalContext: undefined, + }); + }); + }); + + describe('retryAsync', () => { + const { setTimeout: mockSetTimeout } = jest.requireMock<{ + setTimeout: jest.Mock, [number]>; + }>('timers/promises'); + + it('should succeed on first attempt', async () => { + const operation = jest.fn().mockResolvedValue('success'); + const result = await retryAsync(operation, 3, 1000); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(1); + expect(mockSetTimeout).not.toHaveBeenCalled(); + }); + + it('should retry on failure and eventually succeed', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(new Error('Fail 1')) + .mockRejectedValueOnce(new Error('Fail 2')) + .mockResolvedValue('success'); + + const result = await retryAsync(operation, 3, 100); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(3); + expect(mockSetTimeout).toHaveBeenCalledTimes(2); + }); + + it('should use exponential backoff delays', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(new Error('Fail 1')) + .mockRejectedValueOnce(new Error('Fail 2')) + .mockResolvedValue('success'); + + await retryAsync(operation, 3, 100); + + expect(mockSetTimeout).toHaveBeenNthCalledWith(1, 100); // 100 * 2^0 + expect(mockSetTimeout).toHaveBeenNthCalledWith(2, 200); // 100 * 2^1 + }); + + it('should throw last error after max retries', async () => { + const error = new Error('Persistent failure'); + const operation = jest.fn().mockRejectedValue(error); + + await expect(retryAsync(operation, 2, 100)).rejects.toThrow('Persistent failure'); + expect(operation).toHaveBeenCalledTimes(3); // Initial + 2 retries + }); + + it('should log retry attempts', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(new Error('Fail')) + .mockResolvedValue('success'); + + await retryAsync(operation, 3, 100); + + expect(logger.warn).toHaveBeenCalledWith('Retry attempt 1/3 after 100ms', { + error: 'Fail', + }); + }); + + it('should handle non-Error throws', async () => { + const operation = jest.fn().mockRejectedValue('string error'); + + await expect(retryAsync(operation, 2, 100)).rejects.toThrow('string error'); + }); + + it('should use default maxRetries and baseDelay', async () => { + const operation = jest.fn().mockResolvedValue('success'); + const result = await retryAsync(operation); + + expect(result).toBe('success'); + }); + + it('should retry correct number of times with custom maxRetries', async () => { + const operation = jest.fn().mockRejectedValue(new Error('Fail')); + + await expect(retryAsync(operation, 5, 100)).rejects.toThrow(); + expect(operation).toHaveBeenCalledTimes(6); // Initial + 5 retries + }); + + it('should handle undefined throw by converting to error', async () => { + // This is an edge case that shouldn't happen in practice + const operation = jest.fn().mockImplementation(() => { + throw undefined; + }); + + await expect(retryAsync(operation, 1, 100)).rejects.toThrow('undefined'); + }); + + it('should calculate correct delays for multiple retries', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(new Error('1')) + .mockRejectedValueOnce(new Error('2')) + .mockRejectedValueOnce(new Error('3')) + .mockResolvedValue('success'); + + await retryAsync(operation, 5, 1000); + + expect(mockSetTimeout).toHaveBeenNthCalledWith(1, 1000); // 2^0 + expect(mockSetTimeout).toHaveBeenNthCalledWith(2, 2000); // 2^1 + expect(mockSetTimeout).toHaveBeenNthCalledWith(3, 4000); // 2^2 + }); + }); +}); diff --git a/src/test/formatters/formatters.test.ts b/src/test/formatters/formatters.test.ts new file mode 100644 index 0000000..6641e1c --- /dev/null +++ b/src/test/formatters/formatters.test.ts @@ -0,0 +1,730 @@ +/** + * Tests for all formatter modules + * Covers formatting transaction results into Safe JSON format + */ + +import { createMintFormatter } from '../../formatters/mintFormatter'; +import { createAllowListFormatter } from '../../formatters/allowListFormatter'; +import { createRoleManagementFormatter } from '../../formatters/roleManagementFormatter'; +import { createTokenDeploymentFormatter } from '../../formatters/tokenDeploymentFormatter'; +import { createPoolDeploymentFormatter } from '../../formatters/poolDeploymentFormatter'; +import { createChainUpdateFormatter } from '../../formatters/chainUpdateFormatter'; +import { createRateLimiterFormatter } from '../../formatters/rateLimiterFormatter'; +import { createMockInterfaceProvider } from '../helpers'; +import { + TokenPool__factory, + TokenPoolFactory__factory, + FactoryBurnMintERC20__factory, +} from '../../typechain'; +import { SafeOperationType } from '../../types/safe'; + +describe('Formatters', () => { + let mockInterfaceProvider: ReturnType; + + const mockTransaction = { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }; + + const mockBasicMetadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + }; + + const mockMintMetadata = { + ...mockBasicMetadata, + tokenAddress: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + }; + + const mockPoolAddressMetadata = { + ...mockBasicMetadata, + tokenPoolAddress: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + }; + + const mockRoleMetadata = { + ...mockBasicMetadata, + tokenAddress: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + }; + + beforeEach(() => { + mockInterfaceProvider = createMockInterfaceProvider({ + TokenPool: TokenPool__factory.createInterface(), + TokenPoolFactory: TokenPoolFactory__factory.createInterface(), + BurnMintERC20: FactoryBurnMintERC20__factory.createInterface(), + }); + }); + + describe('MintFormatter', () => { + let formatter: ReturnType; + + beforeEach(() => { + formatter = createMintFormatter(mockInterfaceProvider); + }); + + it('should format mint transaction correctly', () => { + const params = { + receiver: '0x1234567890123456789012345678901234567890', + amount: '1000000000000000000', + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result).toMatchObject({ + chainId: mockMintMetadata.chainId, + meta: { + name: 'Mint Tokens', + createdFromSafeAddress: mockMintMetadata.safeAddress, + createdFromOwnerAddress: mockMintMetadata.ownerAddress, + }, + }); + expect(result.transactions).toHaveLength(1); + expect(result.transactions[0]?.contractMethod?.name).toBe('mint'); + }); + + it('should include amount and receiver in description', () => { + const params = { + receiver: '0x1234567890123456789012345678901234567890', + amount: '5000000000000000000', + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.meta.description).toContain(params.amount); + expect(result.meta.description).toContain(params.receiver); + }); + + it('should handle different amounts', () => { + const params1 = { receiver: '0x1234567890123456789012345678901234567890', amount: '1' }; + const params2 = { + receiver: '0x1234567890123456789012345678901234567890', + amount: '999999999999999999999999', + }; + + const result1 = formatter.format(mockTransaction, params1, mockMintMetadata); + const result2 = formatter.format(mockTransaction, params2, mockMintMetadata); + + expect(result1.meta.description).toContain('1'); + expect(result2.meta.description).toContain('999999999999999999999999'); + }); + + it('should use correct contract interface', () => { + const params = { + receiver: '0x1234567890123456789012345678901234567890', + amount: '1000', + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.transactions[0]?.contractMethod).toBeDefined(); + expect(result.transactions[0]?.contractMethod?.name).toBe('mint'); + }); + }); + + describe('AllowListFormatter', () => { + let formatter: ReturnType; + + beforeEach(() => { + formatter = createAllowListFormatter(mockInterfaceProvider); + }); + + it('should format allow list transaction with adds and removes', () => { + const input = { + adds: ['0x1234567890123456789012345678901234567890'], + removes: ['0x0987654321098765432109876543210987654321'], + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.name).toBe('Update Token Pool Allow List'); + expect(result.meta.description).toContain('remove 1 address'); + expect(result.meta.description).toContain('add 1 address'); + }); + + it('should format description for only adds', () => { + const input = { + adds: [ + '0x1234567890123456789012345678901234567890', + '0x0987654321098765432109876543210987654321', + ], + removes: [], + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toContain('add 2 addresses'); + expect(result.meta.description).not.toContain('remove'); + }); + + it('should format description for only removes', () => { + const input = { + adds: [], + removes: ['0x1234567890123456789012345678901234567890'], + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toBe('Update allow list for token pool: remove 1 address'); + expect(result.meta.description).not.toMatch(/\badd\b/); // Match 'add' as whole word + }); + + it('should format description for no changes', () => { + const input = { + adds: [], + removes: [], + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toContain('no changes'); + }); + + it('should use singular form for single address', () => { + const input = { + adds: ['0x1234567890123456789012345678901234567890'], + removes: [], + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toContain('add 1 address'); + expect(result.meta.description).not.toContain('addresses'); + }); + + it('should use plural form for multiple addresses', () => { + const input = { + adds: [ + '0x1234567890123456789012345678901234567890', + '0x0987654321098765432109876543210987654321', + ], + removes: [ + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ], + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toContain('remove 2 addresses'); + expect(result.meta.description).toContain('add 2 addresses'); + }); + + it('should use correct contract interface and function name', () => { + const input = { + adds: ['0x1234567890123456789012345678901234567890'], + removes: [], + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.transactions[0]?.contractMethod?.name).toBe('applyAllowListUpdates'); + }); + }); + + describe('RoleManagementFormatter', () => { + let formatter: ReturnType; + + beforeEach(() => { + formatter = createRoleManagementFormatter(mockInterfaceProvider); + }); + + it('should format grant mint role transaction', () => { + const transactionResult = { + transactions: [mockTransaction], + functionNames: ['grantMintRole' as const], + }; + + const params = { + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + roleType: 'mint' as const, + action: 'grant' as const, + }; + + const result = formatter.format(transactionResult, params, mockRoleMetadata); + + expect(result.meta.name).toBe('Grant Token Pool Roles'); + expect(result.meta.description).toContain('Grant mint role'); + expect(result.meta.description).toContain('to pool'); + }); + + it('should format revoke burn role transaction', () => { + const transactionResult = { + transactions: [mockTransaction], + functionNames: ['revokeBurnRole' as const], + }; + + const params = { + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + roleType: 'burn' as const, + action: 'revoke' as const, + }; + + const result = formatter.format(transactionResult, params, mockRoleMetadata); + + expect(result.meta.name).toBe('Revoke Token Pool Roles'); + expect(result.meta.description).toContain('Revoke burn role'); + expect(result.meta.description).toContain('from pool'); + }); + + it('should format grant both roles transaction', () => { + const transactionResult = { + transactions: [mockTransaction], + functionNames: ['grantMintAndBurnRoles' as const], + }; + + const params = { + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + roleType: 'both' as const, + action: 'grant' as const, + }; + + const result = formatter.format(transactionResult, params, mockRoleMetadata); + + expect(result.meta.description).toContain('Grant mint and burn roles'); + expect(result.meta.description).toContain('to pool'); + }); + + it('should format revoke both roles transaction', () => { + const transactionResult = { + transactions: [mockTransaction, mockTransaction], + functionNames: ['revokeMintRole' as const, 'revokeBurnRole' as const], + }; + + const params = { + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + roleType: 'both' as const, + action: 'revoke' as const, + }; + + const result = formatter.format(transactionResult, params, mockRoleMetadata); + + expect(result.meta.description).toContain('Revoke mint and burn roles'); + expect(result.meta.description).toContain('from pool'); + expect(result.transactions).toHaveLength(2); + }); + + it('should include pool address in description', () => { + const transactionResult = { + transactions: [mockTransaction], + functionNames: ['grantMintRole' as const], + }; + + const params = { + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + roleType: 'mint' as const, + action: 'grant' as const, + }; + + const result = formatter.format(transactionResult, params, mockRoleMetadata); + + expect(result.meta.description).toContain(params.pool); + }); + }); + + describe('TokenDeploymentFormatter', () => { + let formatter: ReturnType; + + beforeEach(() => { + formatter = createTokenDeploymentFormatter(mockInterfaceProvider); + }); + + it('should format token deployment transaction', () => { + const params = { + name: 'Test Token', + symbol: 'TST', + decimals: 18, + maxSupply: '1000000', + preMint: '0', + remoteTokenPools: [], + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.meta.name).toContain('Test Token'); + expect(result.meta.description).toContain('Test Token'); + expect(result.meta.description).toContain('TST'); + }); + + it('should include token name in title', () => { + const params = { + name: 'My Custom Token', + symbol: 'MCT', + decimals: 6, + maxSupply: '1000000', + preMint: '0', + remoteTokenPools: [], + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.meta.name).toBe('Token and Pool Factory Deployment - My Custom Token'); + }); + + it('should use correct contract interface', () => { + const params = { + name: 'Test Token', + symbol: 'TST', + decimals: 18, + maxSupply: '1000000', + preMint: '0', + remoteTokenPools: [], + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.transactions[0]?.contractMethod?.name).toBe('deployTokenAndTokenPool'); + }); + }); + + describe('PoolDeploymentFormatter', () => { + let formatter: ReturnType; + + beforeEach(() => { + formatter = createPoolDeploymentFormatter(mockInterfaceProvider); + }); + + it('should format BurnMintTokenPool deployment', () => { + const params = { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'BurnMintTokenPool' as const, + remoteTokenPools: [], + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.meta.name).toContain('BurnMintTokenPool'); + expect(result.meta.description).toContain('BurnMintTokenPool'); + expect(result.meta.description).toContain(params.token); + }); + + it('should format LockReleaseTokenPool deployment', () => { + const params = { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'LockReleaseTokenPool' as const, + remoteTokenPools: [], + acceptLiquidity: true, + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.meta.name).toContain('LockReleaseTokenPool'); + expect(result.meta.description).toContain('LockReleaseTokenPool'); + }); + + it('should use correct contract interface', () => { + const params = { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'BurnMintTokenPool' as const, + remoteTokenPools: [], + }; + + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + expect(result.transactions[0]?.contractMethod?.name).toBe('deployTokenPoolWithExistingToken'); + }); + }); + + describe('ChainUpdateFormatter', () => { + let formatter: ReturnType; + + beforeEach(() => { + formatter = createChainUpdateFormatter(mockInterfaceProvider); + }); + + it('should format chain update transaction', () => { + const result = formatter.format(mockTransaction, mockPoolAddressMetadata); + + expect(result.meta.name).toBe('Token Pool Chain Updates'); + expect(result.meta.description).toBe('Apply chain updates to the Token Pool contract'); + }); + + it('should set transaction to address to token pool address', () => { + const result = formatter.format(mockTransaction, mockPoolAddressMetadata); + + expect(result.transactions[0]?.to).toBe(mockPoolAddressMetadata.tokenPoolAddress); + }); + + it('should preserve original transaction data except to address', () => { + const result = formatter.format(mockTransaction, mockPoolAddressMetadata); + + expect(result.transactions[0]?.value).toBe(mockTransaction.value); + expect(result.transactions[0]?.data).toBe(mockTransaction.data); + expect(result.transactions[0]?.operation).toBe(mockTransaction.operation); + }); + + it('should use correct contract interface', () => { + const result = formatter.format(mockTransaction, mockPoolAddressMetadata); + + expect(result.transactions[0]?.contractMethod?.name).toBe('applyChainUpdates'); + }); + }); + + describe('RateLimiterFormatter', () => { + let formatter: ReturnType; + + beforeEach(() => { + formatter = createRateLimiterFormatter(mockInterfaceProvider); + }); + + it('should format rate limiter config with both enabled', () => { + const input = { + remoteChainSelector: '16015286601757825753', + outboundConfig: { isEnabled: true, capacity: '1000', rate: '100' }, + inboundConfig: { isEnabled: true, capacity: '2000', rate: '200' }, + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.name).toBe('Update Chain Rate Limiter Configuration'); + expect(result.meta.description).toContain('chain 16015286601757825753'); + expect(result.meta.description).toContain('outbound enabled'); + expect(result.meta.description).toContain('inbound enabled'); + }); + + it('should format rate limiter config with both disabled', () => { + const input = { + remoteChainSelector: '16015286601757825753', + outboundConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toContain('outbound disabled'); + expect(result.meta.description).toContain('inbound disabled'); + }); + + it('should format rate limiter config with mixed states', () => { + const input = { + remoteChainSelector: '16015286601757825753', + outboundConfig: { isEnabled: true, capacity: '1000', rate: '100' }, + inboundConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toContain('outbound enabled'); + expect(result.meta.description).toContain('inbound disabled'); + }); + + it('should include chain selector in description', () => { + const input = { + remoteChainSelector: '5009297550715157269', + outboundConfig: { isEnabled: true, capacity: '1000', rate: '100' }, + inboundConfig: { isEnabled: true, capacity: '2000', rate: '200' }, + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.meta.description).toContain('5009297550715157269'); + }); + + it('should use correct contract interface', () => { + const input = { + remoteChainSelector: '16015286601757825753', + outboundConfig: { isEnabled: true, capacity: '1000', rate: '100' }, + inboundConfig: { isEnabled: true, capacity: '2000', rate: '200' }, + }; + + const result = formatter.format(mockTransaction, input, mockPoolAddressMetadata); + + expect(result.transactions[0]?.contractMethod?.name).toBe('setChainRateLimiterConfig'); + }); + }); + + describe('Common Formatter Behavior', () => { + it('all formatters should include chain ID', () => { + const mintFormatter = createMintFormatter(mockInterfaceProvider); + const allowListFormatter = createAllowListFormatter(mockInterfaceProvider); + const tokenDeploymentFormatter = createTokenDeploymentFormatter(mockInterfaceProvider); + const poolDeploymentFormatter = createPoolDeploymentFormatter(mockInterfaceProvider); + const rateLimiterFormatter = createRateLimiterFormatter(mockInterfaceProvider); + + const mintResult = mintFormatter.format( + mockTransaction, + { receiver: '0x1234567890123456789012345678901234567890', amount: '1' }, + mockMintMetadata, + ); + const allowListResult = allowListFormatter.format( + mockTransaction, + { adds: [], removes: [] }, + mockPoolAddressMetadata, + ); + const tokenResult = tokenDeploymentFormatter.format( + mockTransaction, + { + name: 'T', + symbol: 'T', + decimals: 18, + maxSupply: '1', + preMint: '0', + remoteTokenPools: [], + }, + mockBasicMetadata, + ); + const poolResult = poolDeploymentFormatter.format( + mockTransaction, + { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'BurnMintTokenPool', + remoteTokenPools: [], + }, + mockBasicMetadata, + ); + const rateLimiterResult = rateLimiterFormatter.format( + mockTransaction, + { + remoteChainSelector: '1', + outboundConfig: { isEnabled: true, capacity: '1', rate: '1' }, + inboundConfig: { isEnabled: true, capacity: '1', rate: '1' }, + }, + mockPoolAddressMetadata, + ); + + [mintResult, allowListResult, tokenResult, poolResult, rateLimiterResult].forEach( + (result) => { + expect(result.chainId).toBe(mockBasicMetadata.chainId); + }, + ); + }); + + it('all formatters should include safe address in metadata', () => { + const mintFormatter = createMintFormatter(mockInterfaceProvider); + const allowListFormatter = createAllowListFormatter(mockInterfaceProvider); + const tokenDeploymentFormatter = createTokenDeploymentFormatter(mockInterfaceProvider); + const poolDeploymentFormatter = createPoolDeploymentFormatter(mockInterfaceProvider); + const rateLimiterFormatter = createRateLimiterFormatter(mockInterfaceProvider); + + const mintResult = mintFormatter.format( + mockTransaction, + { receiver: '0x1234567890123456789012345678901234567890', amount: '1' }, + mockMintMetadata, + ); + const allowListResult = allowListFormatter.format( + mockTransaction, + { adds: [], removes: [] }, + mockPoolAddressMetadata, + ); + const tokenResult = tokenDeploymentFormatter.format( + mockTransaction, + { + name: 'T', + symbol: 'T', + decimals: 18, + maxSupply: '1', + preMint: '0', + remoteTokenPools: [], + }, + mockBasicMetadata, + ); + const poolResult = poolDeploymentFormatter.format( + mockTransaction, + { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'BurnMintTokenPool', + remoteTokenPools: [], + }, + mockBasicMetadata, + ); + const rateLimiterResult = rateLimiterFormatter.format( + mockTransaction, + { + remoteChainSelector: '1', + outboundConfig: { isEnabled: true, capacity: '1', rate: '1' }, + inboundConfig: { isEnabled: true, capacity: '1', rate: '1' }, + }, + mockPoolAddressMetadata, + ); + + [mintResult, allowListResult, tokenResult, poolResult, rateLimiterResult].forEach( + (result) => { + expect(result.meta.createdFromSafeAddress).toBe(mockBasicMetadata.safeAddress); + }, + ); + }); + + it('all formatters should include timestamp', () => { + const beforeTime = Date.now(); + + const formatter = createMintFormatter(mockInterfaceProvider); + const params = { + receiver: '0x1234567890123456789012345678901234567890', + amount: '1000', + }; + const result = formatter.format(mockTransaction, params, mockMintMetadata); + + const afterTime = Date.now(); + + expect(result.createdAt).toBeGreaterThanOrEqual(beforeTime); + expect(result.createdAt).toBeLessThanOrEqual(afterTime); + }); + + it('all formatters should return valid Safe JSON structure', () => { + const mintFormatter = createMintFormatter(mockInterfaceProvider); + const allowListFormatter = createAllowListFormatter(mockInterfaceProvider); + const tokenDeploymentFormatter = createTokenDeploymentFormatter(mockInterfaceProvider); + const poolDeploymentFormatter = createPoolDeploymentFormatter(mockInterfaceProvider); + const rateLimiterFormatter = createRateLimiterFormatter(mockInterfaceProvider); + + const mintResult = mintFormatter.format( + mockTransaction, + { receiver: '0x1234567890123456789012345678901234567890', amount: '1' }, + mockMintMetadata, + ); + const allowListResult = allowListFormatter.format( + mockTransaction, + { adds: [], removes: [] }, + mockPoolAddressMetadata, + ); + const tokenResult = tokenDeploymentFormatter.format( + mockTransaction, + { + name: 'T', + symbol: 'T', + decimals: 18, + maxSupply: '1', + preMint: '0', + remoteTokenPools: [], + }, + mockBasicMetadata, + ); + const poolResult = poolDeploymentFormatter.format( + mockTransaction, + { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'BurnMintTokenPool', + remoteTokenPools: [], + }, + mockBasicMetadata, + ); + const rateLimiterResult = rateLimiterFormatter.format( + mockTransaction, + { + remoteChainSelector: '1', + outboundConfig: { isEnabled: true, capacity: '1', rate: '1' }, + inboundConfig: { isEnabled: true, capacity: '1', rate: '1' }, + }, + mockPoolAddressMetadata, + ); + + [mintResult, allowListResult, tokenResult, poolResult, rateLimiterResult].forEach( + (result) => { + expect(result).toHaveProperty('version'); + expect(result).toHaveProperty('chainId'); + expect(result).toHaveProperty('createdAt'); + expect(result).toHaveProperty('meta'); + expect(result).toHaveProperty('transactions'); + expect(Array.isArray(result.transactions)).toBe(true); + }, + ); + }); + }); +}); diff --git a/src/test/generators/acceptOwnership.test.ts b/src/test/generators/acceptOwnership.test.ts new file mode 100644 index 0000000..0627121 --- /dev/null +++ b/src/test/generators/acceptOwnership.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for acceptOwnership generator + * Covers generating transactions to accept ownership of contracts + */ + +import { createAcceptOwnershipGenerator } from '../../generators/acceptOwnership'; +import { SafeOperationType } from '../../types/safe'; +import { AcceptOwnershipError } from '../../errors'; +import { VALID_ADDRESSES, expectValidTransaction } from '../helpers'; +import { ILogger } from '../../interfaces'; + +describe('AcceptOwnershipGenerator', () => { + let generator: ReturnType; + let mockLogger: ILogger; + + beforeEach(() => { + mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + generator = createAcceptOwnershipGenerator(mockLogger); + }); + + describe('generate', () => { + it('should generate accept ownership transaction for a pool address', async () => { + const result = await generator.generate(VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.pool); + expect(result.value).toBe('0'); + // acceptOwnership() has no parameters, calldata is just the function selector + expect(result.data).toMatch(/^0x[a-fA-F0-9]+$/); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should generate accept ownership transaction for a token address', async () => { + const result = await generator.generate(VALID_ADDRESSES.token); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.token); + expect(result.value).toBe('0'); + expect(result.data).toMatch(/^0x[a-fA-F0-9]+$/); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should generate correct function selector for acceptOwnership()', async () => { + const result = await generator.generate(VALID_ADDRESSES.pool); + + // acceptOwnership() function selector is 0x79ba5097 + expect(result.data.slice(0, 10)).toBe('0x79ba5097'); + }); + + it('should generate calldata with only function selector (no parameters)', async () => { + const result = await generator.generate(VALID_ADDRESSES.pool); + + // acceptOwnership() has no parameters, so calldata should be exactly 10 chars (0x + 8 hex) + expect(result.data).toBe('0x79ba5097'); + }); + + it('should throw error for invalid address format', async () => { + await expect(generator.generate('0xinvalid')).rejects.toThrow(AcceptOwnershipError); + await expect(generator.generate('0xinvalid')).rejects.toThrow('Address validation failed'); + }); + + it('should throw error for empty address', async () => { + await expect(generator.generate('')).rejects.toThrow(AcceptOwnershipError); + }); + + it('should throw error for address that is too short', async () => { + await expect(generator.generate('0x1234')).rejects.toThrow(AcceptOwnershipError); + }); + + it('should log info messages during generation', async () => { + await generator.generate(VALID_ADDRESSES.pool); + + expect(mockLogger.info).toHaveBeenCalledWith('Generating accept ownership transaction', { + contractAddress: VALID_ADDRESSES.pool, + }); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Successfully generated accept ownership transaction', + expect.objectContaining({ + contractAddress: VALID_ADDRESSES.pool, + functionSelector: '0x79ba5097', + }), + ); + }); + + it('should work with any valid Ethereum address', async () => { + const addresses = [ + VALID_ADDRESSES.pool, + VALID_ADDRESSES.token, + VALID_ADDRESSES.safe, + VALID_ADDRESSES.receiver, + ]; + + for (const address of addresses) { + const result = await generator.generate(address); + expectValidTransaction(result); + expect(result.to).toBe(address); + expect(result.data).toBe('0x79ba5097'); + } + }); + }); +}); diff --git a/src/test/generators/allowListUpdates.test.ts b/src/test/generators/allowListUpdates.test.ts new file mode 100644 index 0000000..a3674e3 --- /dev/null +++ b/src/test/generators/allowListUpdates.test.ts @@ -0,0 +1,248 @@ +/** + * Tests for allowListUpdates generator + * Covers adding and removing addresses from pool allow lists + */ + +import { createAllowListUpdatesGenerator } from '../../generators/allowListUpdates'; +import { SafeOperationType } from '../../types/safe'; +import { + createMockLogger, + createMockInterfaceProvider, + VALID_ADDRESSES, + expectValidTransaction, + expectValidCalldata, +} from '../helpers'; +import { TokenPool__factory } from '../../typechain'; + +describe('AllowListUpdatesGenerator', () => { + let generator: ReturnType; + let mockLogger: ReturnType; + let mockInterfaceProvider: ReturnType; + + beforeEach(() => { + mockLogger = createMockLogger(); + const tokenPoolInterface = TokenPool__factory.createInterface(); + mockInterfaceProvider = createMockInterfaceProvider({ + TokenPool: tokenPoolInterface, + }); + + generator = createAllowListUpdatesGenerator(mockLogger, mockInterfaceProvider); + }); + + describe('Add Addresses', () => { + it('should generate transaction for adding single address', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver], + removes: [], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.pool); + expect(result.value).toBe('0'); + expectValidCalldata(result.data); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should generate transaction for adding multiple addresses', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver, VALID_ADDRESSES.token, VALID_ADDRESSES.deployer], + removes: [], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle adding zero address', async () => { + const inputJson = JSON.stringify({ + adds: ['0x0000000000000000000000000000000000000000'], + removes: [], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + }); + }); + + describe('Remove Addresses', () => { + it('should generate transaction for removing single address', async () => { + const inputJson = JSON.stringify({ + adds: [], + removes: [VALID_ADDRESSES.receiver], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should generate transaction for removing multiple addresses', async () => { + const inputJson = JSON.stringify({ + adds: [], + removes: [VALID_ADDRESSES.receiver, VALID_ADDRESSES.token], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('Add and Remove Together', () => { + it('should generate transaction for adding and removing addresses', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver], + removes: [VALID_ADDRESSES.token], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should generate transaction for multiple adds and removes', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver, VALID_ADDRESSES.deployer], + removes: [VALID_ADDRESSES.token, VALID_ADDRESSES.safe], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('Error Handling', () => { + it('should throw error for invalid JSON', async () => { + await expect(generator.generate('invalid json', VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for invalid pool address', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver], + removes: [], + }); + + await expect(generator.generate(inputJson, 'invalid-address')).rejects.toThrow(); + }); + + it('should throw error for invalid address in adds', async () => { + const inputJson = JSON.stringify({ + adds: ['invalid-address'], + removes: [], + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for invalid address in removes', async () => { + const inputJson = JSON.stringify({ + adds: [], + removes: ['invalid-address'], + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should use default empty arrays when fields are missing', async () => { + const inputJson1 = JSON.stringify({ + removes: [VALID_ADDRESSES.receiver], + }); + + const inputJson2 = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver], + }); + + const result1 = await generator.generate(inputJson1, VALID_ADDRESSES.pool); + const result2 = await generator.generate(inputJson2, VALID_ADDRESSES.pool); + + expectValidTransaction(result1); + expectValidTransaction(result2); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty add and remove arrays', async () => { + const inputJson = JSON.stringify({ + adds: [], + removes: [], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + }); + + it('should handle same address in both add and remove', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver], + removes: [VALID_ADDRESSES.receiver], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + }); + + it('should handle duplicate addresses in addAddresses', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver, VALID_ADDRESSES.receiver], + removes: [], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + }); + + it('should handle duplicate addresses in removeAddresses', async () => { + const inputJson = JSON.stringify({ + adds: [], + removes: [VALID_ADDRESSES.receiver, VALID_ADDRESSES.receiver], + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + }); + + it('should generate different calldata for different pool addresses', async () => { + const inputJson = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver], + removes: [], + }); + + const result1 = await generator.generate(inputJson, VALID_ADDRESSES.pool); + const result2 = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result1.to).toBe(VALID_ADDRESSES.pool); + expect(result2.to).toBe(VALID_ADDRESSES.token); + }); + + it('should generate different calldata for different operations', async () => { + const inputJsonAdd = JSON.stringify({ + adds: [VALID_ADDRESSES.receiver], + removes: [], + }); + + const inputJsonRemove = JSON.stringify({ + adds: [], + removes: [VALID_ADDRESSES.receiver], + }); + + const resultAdd = await generator.generate(inputJsonAdd, VALID_ADDRESSES.pool); + const resultRemove = await generator.generate(inputJsonRemove, VALID_ADDRESSES.pool); + + expect(resultAdd.data).not.toBe(resultRemove.data); + }); + }); +}); diff --git a/src/test/generators/chainUpdateCalldata.test.ts b/src/test/generators/chainUpdateCalldata.test.ts new file mode 100644 index 0000000..bec5542 --- /dev/null +++ b/src/test/generators/chainUpdateCalldata.test.ts @@ -0,0 +1,458 @@ +/** + * Tests for chainUpdateCalldata generator + * Covers EVM, SVM, MVM chain types and all edge cases + */ + +import { + createChainUpdateGenerator, + convertToContractFormat, +} from '../../generators/chainUpdateCalldata'; +import { ChainType, ChainUpdateInput } from '../../types/chainUpdate'; +import { SafeOperationType } from '../../types/safe'; +import { + createMockLogger, + createMockInterfaceProvider, + BASE_EVM_CHAIN_UPDATE, + BASE_SVM_CHAIN_UPDATE, + VALID_CHAIN_SELECTORS, + VALID_ADDRESSES, + VALID_SOLANA_ADDRESSES, + INVALID_ADDRESSES, + VALID_RATE_LIMITER_CONFIG, + DISABLED_RATE_LIMITER_CONFIG, + expectValidTransaction, + expectValidCalldata, +} from '../helpers'; +import { TokenPool__factory } from '../../typechain'; + +describe('convertToContractFormat', () => { + describe('EVM Chain Type', () => { + it('should convert EVM chain update correctly', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainType: ChainType.EVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.remoteChainSelector).toBe(BASE_EVM_CHAIN_UPDATE.remoteChainSelector); + expect(result.remotePoolAddresses).toHaveLength(1); + expect(result.remoteTokenAddress).toBeDefined(); + expect(result.remoteTokenAddress).toMatch(/^0x[a-f0-9]+$/); + }); + + it('should handle multiple pool addresses for EVM', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remotePoolAddresses: [VALID_ADDRESSES.pool, VALID_ADDRESSES.receiver], + remoteChainType: ChainType.EVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.remotePoolAddresses).toHaveLength(2); + expect(result.remotePoolAddresses[0]).toMatch(/^0x[a-f0-9]+$/); + expect(result.remotePoolAddresses[1]).toMatch(/^0x[a-f0-9]+$/); + }); + + it('should throw error for invalid EVM addresses', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remotePoolAddresses: [INVALID_ADDRESSES.tooShort], + remoteChainType: ChainType.EVM, + } as ChainUpdateInput; + + expect(() => convertToContractFormat(chainUpdate)).toThrow( + /Failed to convert remote evm chain update/, + ); + }); + + it('should throw error for invalid EVM token address', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remoteTokenAddress: INVALID_ADDRESSES.notHex, + remoteChainType: ChainType.EVM, + } as ChainUpdateInput; + + expect(() => convertToContractFormat(chainUpdate)).toThrow( + /Failed to convert remote evm chain update/, + ); + }); + + it('should handle rate limiter configs for EVM', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainType: ChainType.EVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.outboundRateLimiterConfig).toEqual(VALID_RATE_LIMITER_CONFIG); + expect(result.inboundRateLimiterConfig).toEqual(VALID_RATE_LIMITER_CONFIG); + }); + }); + + describe('SVM Chain Type', () => { + it('should convert SVM chain update correctly', () => { + const chainUpdate = { + ...BASE_SVM_CHAIN_UPDATE, + remoteChainType: ChainType.SVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.remoteChainSelector).toBe(BASE_SVM_CHAIN_UPDATE.remoteChainSelector); + expect(result.remotePoolAddresses).toHaveLength(1); + expect(result.remoteTokenAddress).toBeDefined(); + + const EVM_HEX_LENGTH = 42; + expect(result.remoteTokenAddress.length).toBeGreaterThan(EVM_HEX_LENGTH); + }); + + it('should handle multiple pool addresses for SVM', () => { + const chainUpdate = { + ...BASE_SVM_CHAIN_UPDATE, + remotePoolAddresses: [VALID_SOLANA_ADDRESSES.pool, VALID_SOLANA_ADDRESSES.tokenProgram], + remoteChainType: ChainType.SVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.remotePoolAddresses).toHaveLength(2); + }); + + it('should throw error for invalid Solana pool addresses', () => { + const chainUpdate = { + ...BASE_SVM_CHAIN_UPDATE, + remotePoolAddresses: [INVALID_ADDRESSES.invalidSolana], + remoteChainType: ChainType.SVM, + } as ChainUpdateInput; + + expect(() => convertToContractFormat(chainUpdate)).toThrow( + /Failed to convert remote svm chain update/, + ); + }); + + it('should throw error for invalid Solana token address', () => { + const chainUpdate = { + ...BASE_SVM_CHAIN_UPDATE, + remoteTokenAddress: INVALID_ADDRESSES.invalidSolana, + remoteChainType: ChainType.SVM, + } as ChainUpdateInput; + + expect(() => convertToContractFormat(chainUpdate)).toThrow( + /Failed to convert remote svm chain update/, + ); + }); + + it('should handle rate limiter configs for SVM', () => { + const chainUpdate = { + ...BASE_SVM_CHAIN_UPDATE, + remoteChainType: ChainType.SVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.outboundRateLimiterConfig).toEqual(VALID_RATE_LIMITER_CONFIG); + expect(result.inboundRateLimiterConfig).toEqual(VALID_RATE_LIMITER_CONFIG); + }); + }); + + describe('MVM Chain Type', () => { + it('should throw error for MVM (not implemented)', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainType: ChainType.MVM, + } as ChainUpdateInput; + + expect(() => convertToContractFormat(chainUpdate)).toThrow( + /Move Virtual Machine Address validation not implemented/, + ); + }); + }); + + describe('Invalid Chain Type', () => { + it('should throw error for invalid chain type', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainType: 'FakeVM' as ChainType, + } as ChainUpdateInput; + + expect(() => convertToContractFormat(chainUpdate)).toThrow(/Invalid ChainType provided/); + }); + + it('should throw error for undefined chain type', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainType: undefined, + }; + + // @ts-expect-error Testing invalid input + expect(() => convertToContractFormat(chainUpdate)).toThrow(/Invalid ChainType provided/); + }); + + it('should throw error for null chain type', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainType: null, + }; + + // @ts-expect-error Testing invalid input + expect(() => convertToContractFormat(chainUpdate)).toThrow(/Invalid ChainType provided/); + }); + }); + + describe('Rate Limiter Edge Cases', () => { + it('should handle disabled rate limiters', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + outboundRateLimiterConfig: DISABLED_RATE_LIMITER_CONFIG, + inboundRateLimiterConfig: DISABLED_RATE_LIMITER_CONFIG, + remoteChainType: ChainType.EVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.outboundRateLimiterConfig.isEnabled).toBe(false); + expect(result.inboundRateLimiterConfig.isEnabled).toBe(false); + }); + + it('should handle mixed rate limiter states', () => { + const chainUpdate = { + ...BASE_EVM_CHAIN_UPDATE, + outboundRateLimiterConfig: VALID_RATE_LIMITER_CONFIG, + inboundRateLimiterConfig: DISABLED_RATE_LIMITER_CONFIG, + remoteChainType: ChainType.EVM, + } as ChainUpdateInput; + + const result = convertToContractFormat(chainUpdate); + + expect(result.outboundRateLimiterConfig.isEnabled).toBe(true); + expect(result.inboundRateLimiterConfig.isEnabled).toBe(false); + }); + }); +}); + +describe('ChainUpdateGenerator', () => { + let generator: ReturnType; + let mockLogger: ReturnType; + let mockInterfaceProvider: ReturnType; + + beforeEach(() => { + mockLogger = createMockLogger(); + const tokenPoolInterface = TokenPool__factory.createInterface(); + mockInterfaceProvider = createMockInterfaceProvider({ + TokenPool: tokenPoolInterface, + }); + + generator = createChainUpdateGenerator(mockLogger, mockInterfaceProvider); + }); + + describe('EVM Chain Updates', () => { + it('should generate transaction for adding EVM chain', async () => { + const inputJson = JSON.stringify([ + [], // Chain selectors to remove + [ + { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainType: ChainType.EVM, + }, + ], + ]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + expect(result.to).toBe(''); + expect(result.value).toBe('0'); + expectValidCalldata(result.data); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should generate transaction for multiple EVM chains', async () => { + const inputJson = JSON.stringify([ + [], + [ + { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + remoteChainType: ChainType.EVM, + }, + { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainSelector: VALID_CHAIN_SELECTORS.baseSepolia, + remoteChainType: ChainType.EVM, + }, + ], + ]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should generate transaction for removing chains', async () => { + const inputJson = JSON.stringify([ + [VALID_CHAIN_SELECTORS.sepolia, VALID_CHAIN_SELECTORS.baseSepolia], + [], + ]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should generate transaction for adding and removing chains', async () => { + const inputJson = JSON.stringify([ + [VALID_CHAIN_SELECTORS.sepolia], + [ + { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainSelector: VALID_CHAIN_SELECTORS.baseSepolia, + remoteChainType: ChainType.EVM, + }, + ], + ]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('SVM Chain Updates', () => { + it('should generate transaction for adding SVM chain', async () => { + const inputJson = JSON.stringify([ + [], + [ + { + ...BASE_SVM_CHAIN_UPDATE, + remoteChainType: ChainType.SVM, + }, + ], + ]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should generate transaction for mixed EVM and SVM chains', async () => { + const inputJson = JSON.stringify([ + [], + [ + { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + remoteChainType: ChainType.EVM, + }, + { + ...BASE_SVM_CHAIN_UPDATE, + remoteChainSelector: '9999999999999999999', + remoteChainType: ChainType.SVM, + }, + ], + ]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('Error Handling', () => { + it('should throw error for invalid JSON', async () => { + await expect(generator.generate('invalid json')).rejects.toThrow(); + }); + + it('should throw error for malformed input structure', async () => { + const inputJson = JSON.stringify({ + wrong: 'structure', + }); + + await expect(generator.generate(inputJson)).rejects.toThrow(); + }); + + it('should throw error for invalid chain selector', async () => { + const inputJson = JSON.stringify([ + [], + [ + { + ...BASE_EVM_CHAIN_UPDATE, + remoteChainSelector: 'not-a-number', + remoteChainType: ChainType.EVM, + }, + ], + ]); + + await expect(generator.generate(inputJson)).rejects.toThrow(); + }); + + it('should throw error for missing required fields', async () => { + const inputJson = JSON.stringify([ + [], + [ + { + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + // Missing other required fields + }, + ], + ]); + + await expect(generator.generate(inputJson)).rejects.toThrow(); + }); + + it('should allow empty pool addresses array', async () => { + const inputJson = JSON.stringify([ + [], + [ + { + ...BASE_EVM_CHAIN_UPDATE, + remotePoolAddresses: [], + remoteChainType: ChainType.EVM, + }, + ], + ]); + + const result = await generator.generate(inputJson); + expectValidTransaction(result); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty chain updates', async () => { + const inputJson = JSON.stringify([[], []]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + }); + + it('should handle large capacity values', async () => { + const inputJson = JSON.stringify([ + [], + [ + { + ...BASE_EVM_CHAIN_UPDATE, + outboundRateLimiterConfig: { + isEnabled: true, + capacity: '999999999999999999999999', + rate: '999999999999999999999999', + }, + remoteChainType: ChainType.EVM, + }, + ], + ]); + + const result = await generator.generate(inputJson); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); +}); diff --git a/src/test/generators/poolDeployment.test.ts b/src/test/generators/poolDeployment.test.ts new file mode 100644 index 0000000..4630bc8 --- /dev/null +++ b/src/test/generators/poolDeployment.test.ts @@ -0,0 +1,430 @@ +/** + * Tests for poolDeployment generator + * Covers pool-only deployment for existing tokens + */ + +import { createPoolDeploymentGenerator } from '../../generators/poolDeployment'; +import { SafeOperationType } from '../../types/safe'; +import { + createMockLogger, + createMockInterfaceProvider, + VALID_ADDRESSES, + VALID_SALTS, + INVALID_SALTS, + VALID_POOL_PARAMS, + expectValidTransaction, + expectValidCalldata, +} from '../helpers'; +import { TokenPoolFactory__factory } from '../../typechain'; + +describe('PoolDeploymentGenerator', () => { + let generator: ReturnType; + let mockLogger: ReturnType; + let mockInterfaceProvider: ReturnType; + + beforeEach(() => { + mockLogger = createMockLogger(); + const factoryInterface = TokenPoolFactory__factory.createInterface(); + mockInterfaceProvider = createMockInterfaceProvider({ + TokenPoolFactory: factoryInterface, + }); + + generator = createPoolDeploymentGenerator(mockLogger, mockInterfaceProvider); + }); + + describe('BurnMintTokenPool Deployment', () => { + it('should generate transaction for BurnMintTokenPool deployment', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.deployer); + expect(result.value).toBe('0'); + expectValidCalldata(result.data); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should handle different salts for BurnMint pools', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result1 = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.zero, + ); + + const result2 = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result1); + expectValidTransaction(result2); + // Both should generate valid but different calldata + expect(result1.data).not.toBe(result2.data); + }); + + it('should handle different token addresses', async () => { + const inputJson1 = JSON.stringify({ + ...VALID_POOL_PARAMS, + token: VALID_ADDRESSES.token, + poolType: 'BurnMintTokenPool', + }); + + const inputJson2 = JSON.stringify({ + ...VALID_POOL_PARAMS, + token: VALID_ADDRESSES.receiver, + poolType: 'BurnMintTokenPool', + }); + + const result1 = await generator.generate( + inputJson1, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + const result2 = await generator.generate( + inputJson2, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result1); + expectValidTransaction(result2); + expect(result1.data).not.toBe(result2.data); + }); + }); + + describe('LockReleaseTokenPool Deployment', () => { + it('should generate transaction for LockReleaseTokenPool deployment', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'LockReleaseTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.deployer); + expectValidCalldata(result.data); + }); + + it('should handle acceptLiquidity parameter for LockRelease', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'LockReleaseTokenPool', + acceptLiquidity: true, + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle acceptLiquidity false for LockRelease', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'LockReleaseTokenPool', + acceptLiquidity: false, + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('Decimals Parameter', () => { + it('should handle different decimals values', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + decimals: 6, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result); + }); + }); + + describe('Error Handling', () => { + it('should throw error for invalid JSON', async () => { + await expect( + generator.generate('invalid json', VALID_ADDRESSES.deployer, VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + + it('should throw error for invalid factory address', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, 'invalid-address', VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + + it('should throw error for invalid token address', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + token: 'invalid-address', + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + + it('should throw error for missing token field', async () => { + const inputJson = JSON.stringify({ + decimals: 18, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + + it('should throw error for missing poolType field', async () => { + const inputJson = JSON.stringify({ + token: VALID_ADDRESSES.token, + decimals: 18, + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + + it('should throw error for missing decimals field', async () => { + const inputJson = JSON.stringify({ + token: VALID_ADDRESSES.token, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + + it('should throw error for invalid pool type', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'InvalidPoolType', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + + it('should throw error for invalid decimals value', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + decimals: -1, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, VALID_SALTS.sequential), + ).rejects.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle deployment with remoteTokenPools', async () => { + const inputJson = JSON.stringify({ + token: VALID_ADDRESSES.token, + decimals: 18, + poolType: 'BurnMintTokenPool', + remoteTokenPools: [], + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result); + }); + + it('should handle deployment with minimal parameters', async () => { + const inputJson = JSON.stringify({ + token: VALID_ADDRESSES.token, + decimals: 18, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(result); + }); + + it('should generate different calldata for different pool types with same token', async () => { + const inputJsonBurnMint = JSON.stringify({ + token: VALID_ADDRESSES.token, + decimals: 18, + poolType: 'BurnMintTokenPool', + }); + + const inputJsonLockRelease = JSON.stringify({ + token: VALID_ADDRESSES.token, + decimals: 18, + poolType: 'LockReleaseTokenPool', + }); + + const resultBurnMint = await generator.generate( + inputJsonBurnMint, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + const resultLockRelease = await generator.generate( + inputJsonLockRelease, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + ); + + expectValidTransaction(resultBurnMint); + expectValidTransaction(resultLockRelease); + expect(resultBurnMint.data).not.toBe(resultLockRelease.data); + }); + }); + + describe('Salt Validation', () => { + it('should reject salt that is too short', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, INVALID_SALTS.tooShort), + ).rejects.toThrow(); + }); + + it('should reject salt that is too long', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, INVALID_SALTS.tooLong), + ).rejects.toThrow(); + }); + + it('should reject non-hex salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, INVALID_SALTS.notHex), + ).rejects.toThrow(); + }); + + it('should reject salt missing 0x prefix', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, INVALID_SALTS.missing0x), + ).rejects.toThrow(); + }); + + it('should reject empty salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.deployer, '')).rejects.toThrow( + /required/i, + ); + }); + + it('should reject undefined salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, undefined as unknown as string), + ).rejects.toThrow(); + }); + + it('should accept valid 32-byte zero salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.zero, + ); + + expectValidTransaction(result); + }); + + it('should accept valid 32-byte random salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_POOL_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.random, + ); + + expectValidTransaction(result); + }); + }); +}); diff --git a/src/test/generators/rateLimiterConfig.test.ts b/src/test/generators/rateLimiterConfig.test.ts new file mode 100644 index 0000000..624c1a5 --- /dev/null +++ b/src/test/generators/rateLimiterConfig.test.ts @@ -0,0 +1,312 @@ +/** + * Tests for rateLimiterConfig generator + * Covers setting rate limiter configurations for token pools + */ + +import { createRateLimiterConfigGenerator } from '../../generators/rateLimiterConfig'; +import { SafeOperationType } from '../../types/safe'; +import { + createMockLogger, + createMockInterfaceProvider, + VALID_ADDRESSES, + VALID_CHAIN_SELECTORS, + VALID_RATE_LIMITER_CONFIG, + DISABLED_RATE_LIMITER_CONFIG, + expectValidTransaction, + expectValidCalldata, +} from '../helpers'; +import { TokenPool__factory } from '../../typechain'; + +describe('RateLimiterConfigGenerator', () => { + let generator: ReturnType; + let mockLogger: ReturnType; + let mockInterfaceProvider: ReturnType; + + beforeEach(() => { + mockLogger = createMockLogger(); + const tokenPoolInterface = TokenPool__factory.createInterface(); + mockInterfaceProvider = createMockInterfaceProvider({ + TokenPool: tokenPoolInterface, + }); + + generator = createRateLimiterConfigGenerator(mockLogger, mockInterfaceProvider); + }); + + describe('Valid Rate Limiter Configs', () => { + it('should generate transaction for setting rate limiter config', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.pool); + expect(result.value).toBe('0'); + expectValidCalldata(result.data); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should handle enabled rate limiters with custom values', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.baseSepolia, + outboundConfig: { + isEnabled: true, + capacity: '5000000', + rate: '500000', + }, + inboundConfig: { + isEnabled: true, + capacity: '10000000', + rate: '1000000', + }, + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle disabled rate limiters', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: DISABLED_RATE_LIMITER_CONFIG, + inboundConfig: DISABLED_RATE_LIMITER_CONFIG, + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle mixed enabled/disabled rate limiters', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: DISABLED_RATE_LIMITER_CONFIG, + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle large capacity and rate values', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: { + isEnabled: true, + capacity: '999999999999999999999999', + rate: '999999999999999999999999', + }, + inboundConfig: { + isEnabled: true, + capacity: '999999999999999999999999', + rate: '999999999999999999999999', + }, + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('Different Chain Selectors', () => { + it('should handle different chain selectors', async () => { + const inputJson1 = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + const inputJson2 = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.baseSepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + const result1 = await generator.generate(inputJson1, VALID_ADDRESSES.pool); + const result2 = await generator.generate(inputJson2, VALID_ADDRESSES.pool); + + expectValidTransaction(result1); + expectValidTransaction(result2); + expect(result1.data).not.toBe(result2.data); + }); + + it('should handle multiple different chain selectors', async () => { + const chainSelectors = [ + VALID_CHAIN_SELECTORS.sepolia, + VALID_CHAIN_SELECTORS.baseSepolia, + VALID_CHAIN_SELECTORS.ethereum, + ]; + + const results = await Promise.all( + chainSelectors.map((selector) => { + const inputJson = JSON.stringify({ + remoteChainSelector: selector, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + return generator.generate(inputJson, VALID_ADDRESSES.pool); + }), + ); + + results.forEach((result) => { + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + }); + + describe('Error Handling', () => { + it('should throw error for invalid JSON', async () => { + await expect(generator.generate('invalid json', VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for invalid pool address', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, 'invalid-address')).rejects.toThrow(); + }); + + it('should throw error for missing remoteChainSelector', async () => { + const inputJson = JSON.stringify({ + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for missing outboundRateLimiterConfig', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for missing inboundRateLimiterConfig', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for invalid chain selector', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: 'not-a-number', + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for negative capacity', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: { + isEnabled: true, + capacity: '-1000', + rate: '100', + }, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for negative rate', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: { + isEnabled: true, + capacity: '1000', + rate: '-100', + }, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + + it('should throw error for non-numeric capacity', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: { + isEnabled: true, + capacity: 'not-a-number', + rate: '100', + }, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.pool)).rejects.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero capacity and rate when disabled', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: { + isEnabled: false, + capacity: '0', + rate: '0', + }, + inboundConfig: { + isEnabled: false, + capacity: '0', + rate: '0', + }, + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle different pool addresses', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + const result1 = await generator.generate(inputJson, VALID_ADDRESSES.pool); + const result2 = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result1.to).toBe(VALID_ADDRESSES.pool); + expect(result2.to).toBe(VALID_ADDRESSES.token); + }); + + it('should generate same calldata for same inputs', async () => { + const inputJson = JSON.stringify({ + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + outboundConfig: VALID_RATE_LIMITER_CONFIG, + inboundConfig: VALID_RATE_LIMITER_CONFIG, + }); + + const result1 = await generator.generate(inputJson, VALID_ADDRESSES.pool); + const result2 = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expect(result1.data).toBe(result2.data); + }); + }); +}); diff --git a/src/test/generators/roleManagement.test.ts b/src/test/generators/roleManagement.test.ts new file mode 100644 index 0000000..c65645c --- /dev/null +++ b/src/test/generators/roleManagement.test.ts @@ -0,0 +1,275 @@ +/** + * Tests for roleManagement generator + * Covers granting and revoking mint/burn roles + */ + +import { createRoleManagementGenerator } from '../../generators/roleManagement'; +import { SafeOperationType } from '../../types/safe'; +import { + createMockLogger, + createMockInterfaceProvider, + VALID_ADDRESSES, + VALID_ROLE_PARAMS, + expectValidTransaction, + expectValidCalldata, + expectValidTransactionArray, +} from '../helpers'; +import { FactoryBurnMintERC20__factory } from '../../typechain'; + +describe('RoleManagementGenerator', () => { + let generator: ReturnType; + let mockLogger: ReturnType; + let mockInterfaceProvider: ReturnType; + + beforeEach(() => { + mockLogger = createMockLogger(); + const tokenInterface = FactoryBurnMintERC20__factory.createInterface(); + mockInterfaceProvider = createMockInterfaceProvider({ + BurnMintERC20: tokenInterface, + }); + + generator = createRoleManagementGenerator(mockLogger, mockInterfaceProvider); + }); + + describe('Grant Roles', () => { + it('should generate transaction for granting both mint and burn roles', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'both', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(1); + expectValidTransactionArray(result.transactions); + expect(result.functionNames[0]).toBe('grantMintAndBurnRoles'); + + const tx = result.transactions[0]!; + expect(tx.to).toBe(VALID_ADDRESSES.token); + expect(tx.value).toBe('0'); + expectValidCalldata(tx.data); + expect(tx.operation).toBe(SafeOperationType.Call); + }); + + it('should generate transaction for granting only mint role', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'mint', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(1); + expectValidTransaction(result.transactions[0]!); + expect(result.transactions[0]!.to).toBe(VALID_ADDRESSES.token); + expectValidCalldata(result.transactions[0]!.data); + }); + + it('should generate transaction for granting only burn role', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'burn', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(1); + expectValidTransaction(result.transactions[0]!); + expect(result.transactions[0]!.to).toBe(VALID_ADDRESSES.token); + expectValidCalldata(result.transactions[0]!.data); + }); + + it('should generate different calldata for mint vs burn roles', async () => { + const inputJsonMint = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'mint', + }); + + const inputJsonBurn = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'burn', + }); + + const resultMint = await generator.generate(inputJsonMint, VALID_ADDRESSES.token); + const resultBurn = await generator.generate(inputJsonBurn, VALID_ADDRESSES.token); + + expect(resultMint.transactions[0]!.data).not.toBe(resultBurn.transactions[0]!.data); + }); + + it('should handle different pool addresses', async () => { + const inputJson1 = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'both', + }); + + const inputJson2 = JSON.stringify({ + pool: VALID_ADDRESSES.receiver, + roleType: 'both', + }); + + const result1 = await generator.generate(inputJson1, VALID_ADDRESSES.token); + const result2 = await generator.generate(inputJson2, VALID_ADDRESSES.token); + + expect(result1.transactions).toHaveLength(1); + expect(result2.transactions).toHaveLength(1); + expect(result1.transactions[0]!.data).not.toBe(result2.transactions[0]!.data); + }); + }); + + describe('Revoke Roles', () => { + it('should generate transactions for revoking both mint and burn roles', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'both', + action: 'revoke', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(2); + expectValidTransactionArray(result.transactions); + }); + + it('should generate transaction for revoking only mint role', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'mint', + action: 'revoke', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(1); + expectValidTransaction(result.transactions[0]!); + }); + + it('should generate transaction for revoking only burn role', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'burn', + action: 'revoke', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(1); + expectValidTransaction(result.transactions[0]!); + }); + + it('should generate different calldata for grant vs revoke', async () => { + const inputJsonGrant = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'mint', + action: 'grant', + }); + + const inputJsonRevoke = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'mint', + action: 'revoke', + }); + + const resultGrant = await generator.generate(inputJsonGrant, VALID_ADDRESSES.token); + const resultRevoke = await generator.generate(inputJsonRevoke, VALID_ADDRESSES.token); + + expect(resultGrant.transactions[0]!.data).not.toBe(resultRevoke.transactions[0]!.data); + }); + }); + + describe('Error Handling', () => { + it('should throw error for invalid JSON', async () => { + await expect(generator.generate('invalid json', VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for invalid token address', async () => { + const inputJson = JSON.stringify(VALID_ROLE_PARAMS); + + await expect(generator.generate(inputJson, 'invalid-address')).rejects.toThrow(); + }); + + it('should throw error for invalid pool address', async () => { + const inputJson = JSON.stringify({ + pool: 'invalid-address', + roleType: 'both', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for missing pool address', async () => { + const inputJson = JSON.stringify({ + roleType: 'both', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should use default role type "both" when not specified', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(1); + expect(result.functionNames[0]).toBe('grantMintAndBurnRoles'); + }); + + it('should throw error for invalid role type', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'invalid-role', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle granting roles to zero address', async () => { + const inputJson = JSON.stringify({ + pool: '0x0000000000000000000000000000000000000000', + roleType: 'both', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result.transactions).toHaveLength(1); + expectValidTransactionArray(result.transactions); + expect(result.functionNames[0]).toBe('grantMintAndBurnRoles'); + }); + + it('should handle different token addresses', async () => { + const inputJson = JSON.stringify(VALID_ROLE_PARAMS); + + const result1 = await generator.generate(inputJson, VALID_ADDRESSES.token); + const result2 = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expect(result1.transactions[0]!.to).toBe(VALID_ADDRESSES.token); + expect(result2.transactions[0]!.to).toBe(VALID_ADDRESSES.pool); + }); + + it('should default to grant when action is not specified', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'mint', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result.transactions[0]!); + }); + + it('should handle action: "grant" explicitly', async () => { + const inputJson = JSON.stringify({ + pool: VALID_ADDRESSES.pool, + roleType: 'mint', + action: 'grant', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result.transactions[0]!); + }); + }); +}); diff --git a/src/test/generators/tokenDeployment.test.ts b/src/test/generators/tokenDeployment.test.ts new file mode 100644 index 0000000..0c2bd1c --- /dev/null +++ b/src/test/generators/tokenDeployment.test.ts @@ -0,0 +1,497 @@ +/** + * Tests for tokenDeployment generator + * Covers token and pool deployment with CREATE2 address computation + */ + +import { createTokenDeploymentGenerator } from '../../generators/tokenDeployment'; +import { SafeOperationType } from '../../types/safe'; +import { + createMockLogger, + createMockInterfaceProvider, + createMockAddressComputer, + VALID_ADDRESSES, + VALID_SALTS, + INVALID_SALTS, + VALID_TOKEN_PARAMS, + expectValidTransaction, + expectValidCalldata, +} from '../helpers'; +import { TokenPoolFactory__factory, FactoryBurnMintERC20__factory } from '../../typechain'; + +describe('TokenDeploymentGenerator', () => { + let generator: ReturnType; + let mockLogger: ReturnType; + let mockInterfaceProvider: ReturnType; + let mockAddressComputer: ReturnType; + + const predictedTokenAddress = '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa'; + + beforeEach(() => { + mockLogger = createMockLogger(); + const factoryInterface = TokenPoolFactory__factory.createInterface(); + const tokenInterface = FactoryBurnMintERC20__factory.createInterface(); + mockInterfaceProvider = createMockInterfaceProvider({ + TokenPoolFactory: factoryInterface, + BurnMintERC20: tokenInterface, + }); + mockAddressComputer = createMockAddressComputer(predictedTokenAddress); + + generator = createTokenDeploymentGenerator( + mockLogger, + mockInterfaceProvider, + mockAddressComputer, + ); + }); + + describe('BurnMintTokenPool Deployment', () => { + it('should generate transaction for token and BurnMintTokenPool deployment', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.deployer); + expect(result.value).toBe('0'); + expectValidCalldata(result.data); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should generate different calldata for different salts', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result1 = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.zero, + VALID_ADDRESSES.safe, + ); + + const result2 = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result1); + expectValidTransaction(result2); + // Different salts should generate different calldata + expect(result1.data).not.toBe(result2.data); + }); + + it('should handle zero preMint', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + preMint: '0', + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle non-zero preMint', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + preMint: '1000000000000000000000', + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle large maxSupply values', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + maxSupply: '999999999999999999999999999999', + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('LockReleaseTokenPool Deployment', () => { + it('should generate transaction for token and LockReleaseTokenPool deployment', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'LockReleaseTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.deployer); + expectValidCalldata(result.data); + }); + + it('should accept valid acceptLiquidity for LockRelease', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'LockReleaseTokenPool', + acceptLiquidity: true, + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + }); + }); + + describe('Token Parameters', () => { + it('should handle different decimal values', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + decimals: 6, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + }); + + it('should handle different token names and symbols', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + name: 'My Custom Token', + symbol: 'MCT', + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + }); + }); + + describe('Error Handling', () => { + it('should throw error for invalid JSON', async () => { + await expect( + generator.generate( + 'invalid json', + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should throw error for invalid factory address', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + 'invalid-address', + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should throw error for invalid safe address', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + 'invalid-address', + ), + ).rejects.toThrow(); + }); + + it('should throw error for missing required fields', async () => { + const inputJson = JSON.stringify({ + name: 'Test', + // Missing symbol, decimals, etc. + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should throw error for negative decimals', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + decimals: -1, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should throw error for decimals > 18', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + decimals: 19, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty token name', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + name: '', + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should handle empty symbol', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + symbol: '', + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should handle preMint exceeding maxSupply', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + maxSupply: '1000', + preMint: '10000', + poolType: 'BurnMintTokenPool', + }); + + // This validation might be done in the contract, not the generator + // If generator validates, this should throw. Otherwise, it should succeed. + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.sequential, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + }); + }); + + describe('Salt Validation', () => { + it('should reject salt that is too short', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + INVALID_SALTS.tooShort, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should reject salt that is too long', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + INVALID_SALTS.tooLong, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should reject non-hex salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + INVALID_SALTS.notHex, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should reject salt missing 0x prefix', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + INVALID_SALTS.missing0x, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should reject empty salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate(inputJson, VALID_ADDRESSES.deployer, '', VALID_ADDRESSES.safe), + ).rejects.toThrow(/required/i); + }); + + it('should reject undefined salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + await expect( + generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + undefined as unknown as string, + VALID_ADDRESSES.safe, + ), + ).rejects.toThrow(); + }); + + it('should accept valid 32-byte zero salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.zero, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + }); + + it('should accept valid 32-byte random salt', async () => { + const inputJson = JSON.stringify({ + ...VALID_TOKEN_PARAMS, + poolType: 'BurnMintTokenPool', + }); + + const result = await generator.generate( + inputJson, + VALID_ADDRESSES.deployer, + VALID_SALTS.random, + VALID_ADDRESSES.safe, + ); + + expectValidTransaction(result); + }); + }); +}); diff --git a/src/test/generators/tokenMint.test.ts b/src/test/generators/tokenMint.test.ts new file mode 100644 index 0000000..edbf01d --- /dev/null +++ b/src/test/generators/tokenMint.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for tokenMint generator + * Covers token minting functionality + */ + +import { createTokenMintGenerator } from '../../generators/tokenMint'; +import { SafeOperationType } from '../../types/safe'; +import { + createMockLogger, + createMockInterfaceProvider, + VALID_ADDRESSES, + VALID_MINT_PARAMS, + expectValidTransaction, + expectValidCalldata, +} from '../helpers'; +import { FactoryBurnMintERC20__factory } from '../../typechain'; + +describe('TokenMintGenerator', () => { + let generator: ReturnType; + let mockLogger: ReturnType; + let mockInterfaceProvider: ReturnType; + + beforeEach(() => { + mockLogger = createMockLogger(); + const tokenInterface = FactoryBurnMintERC20__factory.createInterface(); + mockInterfaceProvider = createMockInterfaceProvider({ + BurnMintERC20: tokenInterface, + }); + + generator = createTokenMintGenerator(mockLogger, mockInterfaceProvider); + }); + + describe('Valid Mint Operations', () => { + it('should generate transaction for minting tokens', async () => { + const inputJson = JSON.stringify(VALID_MINT_PARAMS); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result); + expect(result.to).toBe(VALID_ADDRESSES.token); + expect(result.value).toBe('0'); + expectValidCalldata(result.data); + expect(result.operation).toBe(SafeOperationType.Call); + }); + + it('should handle small amounts', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: '1', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle large amounts', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: '999999999999999999999999999999', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle different receiver addresses', async () => { + const inputJson1 = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: '1000000000000000000000', + }); + + const inputJson2 = JSON.stringify({ + receiver: VALID_ADDRESSES.pool, + amount: '1000000000000000000000', + }); + + const result1 = await generator.generate(inputJson1, VALID_ADDRESSES.token); + const result2 = await generator.generate(inputJson2, VALID_ADDRESSES.token); + + expectValidTransaction(result1); + expectValidTransaction(result2); + expect(result1.data).not.toBe(result2.data); + }); + + it('should handle minting to zero address', async () => { + const inputJson = JSON.stringify({ + receiver: '0x0000000000000000000000000000000000000000', + amount: '1000000000000000000000', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + }); + + describe('Error Handling', () => { + it('should throw error for invalid JSON', async () => { + await expect(generator.generate('invalid json', VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for invalid token address', async () => { + const inputJson = JSON.stringify(VALID_MINT_PARAMS); + + await expect(generator.generate(inputJson, 'invalid-address')).rejects.toThrow(); + }); + + it('should throw error for invalid receiver address', async () => { + const inputJson = JSON.stringify({ + receiver: 'invalid-address', + amount: '1000000000000000000000', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for missing receiver field', async () => { + const inputJson = JSON.stringify({ + amount: '1000000000000000000000', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for missing amount field', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for negative amount', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: '-1000', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for non-numeric amount', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: 'not-a-number', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + + it('should throw error for empty amount string', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: '', + }); + + await expect(generator.generate(inputJson, VALID_ADDRESSES.token)).rejects.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero amount', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: '0', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle amount with leading zeros', async () => { + const inputJson = JSON.stringify({ + receiver: VALID_ADDRESSES.receiver, + amount: '00001000', + }); + + const result = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expectValidTransaction(result); + expectValidCalldata(result.data); + }); + + it('should handle different token addresses', async () => { + const inputJson = JSON.stringify(VALID_MINT_PARAMS); + + const result1 = await generator.generate(inputJson, VALID_ADDRESSES.token); + const result2 = await generator.generate(inputJson, VALID_ADDRESSES.pool); + + expectValidTransaction(result1); + expectValidTransaction(result2); + expect(result1.to).toBe(VALID_ADDRESSES.token); + expect(result2.to).toBe(VALID_ADDRESSES.pool); + }); + + it('should generate same calldata for same inputs', async () => { + const inputJson = JSON.stringify(VALID_MINT_PARAMS); + + const result1 = await generator.generate(inputJson, VALID_ADDRESSES.token); + const result2 = await generator.generate(inputJson, VALID_ADDRESSES.token); + + expect(result1.data).toBe(result2.data); + }); + }); +}); diff --git a/src/test/helpers/fixtures.ts b/src/test/helpers/fixtures.ts new file mode 100644 index 0000000..65b5ca8 --- /dev/null +++ b/src/test/helpers/fixtures.ts @@ -0,0 +1,149 @@ +/** + * Shared test fixtures + * Common test data used across multiple test files + */ + +import { ChainType, ChainUpdateInput } from '../../types/chainUpdate'; +import { RateLimiterConfig } from '../../types/poolDeployment'; + +/** + * Valid Ethereum addresses for testing + */ +export const VALID_ADDRESSES = { + deployer: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + safe: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + receiver: '0x1234567890123456789012345678901234567890', + owner: '0x0000000000000000000000000000000000000000', +} as const; + +/** + * Valid Solana addresses for testing SVM chains + */ +export const VALID_SOLANA_ADDRESSES = { + pool: '11111111111111111111111111111112', + token: 'So11111111111111111111111111111111111111112', + tokenProgram: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', +} as const; + +/** + * Invalid addresses for negative testing + */ +export const INVALID_ADDRESSES = { + tooShort: '0x12abc1234def', + notHex: 'not-an-address', + invalidSolana: 'invalid-solana-address', + empty: '', +} as const; + +/** + * Valid salt values (32 bytes) + */ +export const VALID_SALTS = { + zero: '0x0000000000000000000000000000000000000000000000000000000000000000', + sequential: '0x0000000000000000000000000000000000000000000000000000000123456789', + random: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', +} as const; + +/** + * Invalid salt values for negative testing + */ +export const INVALID_SALTS = { + tooShort: '0x123456', + tooLong: '0x' + '1'.repeat(66), + notHex: '0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG', + missing0x: '0000000000000000000000000000000000000000000000000000000000000000', +} as const; + +/** + * Valid chain selectors + */ +export const VALID_CHAIN_SELECTORS = { + ethereum: '5009297550715157269', + baseMainnet: '15971525489660198786', + baseSepolia: '10344971235874465080', + arbitrumMainnet: '4949039107694359620', + sepolia: '12532609583862916517', +} as const; + +/** + * Valid rate limiter configuration + */ +export const VALID_RATE_LIMITER_CONFIG: RateLimiterConfig = { + isEnabled: true, + capacity: '1000000', + rate: '100000', +} as const; + +/** + * Disabled rate limiter configuration + */ +export const DISABLED_RATE_LIMITER_CONFIG: RateLimiterConfig = { + isEnabled: false, + capacity: '0', + rate: '0', +} as const; + +/** + * Base chain update input for EVM chains + */ +export const BASE_EVM_CHAIN_UPDATE: Partial = { + remoteChainSelector: VALID_CHAIN_SELECTORS.sepolia, + remotePoolAddresses: [VALID_ADDRESSES.pool], + remoteTokenAddress: VALID_ADDRESSES.token, + outboundRateLimiterConfig: VALID_RATE_LIMITER_CONFIG, + inboundRateLimiterConfig: VALID_RATE_LIMITER_CONFIG, + remoteChainType: ChainType.EVM, +} as const; + +/** + * Base chain update input for SVM chains + */ +export const BASE_SVM_CHAIN_UPDATE: Partial = { + remoteChainSelector: '1234567890123456789', + remotePoolAddresses: [VALID_SOLANA_ADDRESSES.pool], + remoteTokenAddress: VALID_SOLANA_ADDRESSES.token, + outboundRateLimiterConfig: VALID_RATE_LIMITER_CONFIG, + inboundRateLimiterConfig: VALID_RATE_LIMITER_CONFIG, + remoteChainType: ChainType.SVM, +} as const; + +/** + * Token deployment parameters for testing + */ +export const VALID_TOKEN_PARAMS = { + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + maxSupply: '1000000000000000000000000', + preMint: '0', + burnerAddress: VALID_ADDRESSES.owner, + poolType: 'BurnMintTokenPool' as const, +} as const; + +/** + * Pool deployment parameters for testing + */ +export const VALID_POOL_PARAMS = { + token: VALID_ADDRESSES.token, + decimals: 18, + poolType: 'BurnMintTokenPool' as const, +} as const; + +/** + * Mint parameters for testing + */ +export const VALID_MINT_PARAMS = { + receiver: VALID_ADDRESSES.receiver, + amount: '1000000000000000000000', +} as const; + +/** + * Role management parameters for testing + */ +export const VALID_ROLE_PARAMS = { + pool: VALID_ADDRESSES.pool, + roleType: 'both' as const, + action: 'grant' as const, +} as const; diff --git a/src/test/helpers/index.ts b/src/test/helpers/index.ts new file mode 100644 index 0000000..6395b89 --- /dev/null +++ b/src/test/helpers/index.ts @@ -0,0 +1,8 @@ +/** + * Test helpers barrel export + * Centralized export for all test utilities + */ + +export * from './mockFactories'; +export * from './fixtures'; +export * from './testHelpers'; diff --git a/src/test/helpers/mockFactories.ts b/src/test/helpers/mockFactories.ts new file mode 100644 index 0000000..1887bf4 --- /dev/null +++ b/src/test/helpers/mockFactories.ts @@ -0,0 +1,117 @@ +/** + * Mock factories for testing + * Provides lightweight test doubles for all interfaces + */ + +import { ILogger, IInterfaceProvider, IAddressComputer } from '../../interfaces'; +import { Interface } from 'ethers'; + +/** + * Creates a mock logger that silently discards all log messages + * Use for tests where logging output is not relevant + */ +export function createMockLogger(): ILogger { + const noop = (): void => {}; + return { + error: noop, + warn: noop, + info: noop, + debug: noop, + }; +} + +/** + * Type for metadata that can be passed to logger + */ +type LogMetadata = Record | undefined; + +/** + * Type for captured log calls + */ +type LogCall = [string, LogMetadata]; + +/** + * Spy logger type with calls tracking + */ +type SpyLogger = ILogger & { + calls: { + error: LogCall[]; + warn: LogCall[]; + info: LogCall[]; + debug: LogCall[]; + }; +}; + +/** + * Creates a spy logger that captures log calls for assertions + * Use when you need to verify logging behavior + */ +export function createSpyLogger(): SpyLogger { + const calls = { + error: [] as LogCall[], + warn: [] as LogCall[], + info: [] as LogCall[], + debug: [] as LogCall[], + }; + + const spyLogger: SpyLogger = { + error: (msg: string, meta?: LogMetadata): void => { + calls.error.push([msg, meta]); + }, + warn: (msg: string, meta?: LogMetadata): void => { + calls.warn.push([msg, meta]); + }, + info: (msg: string, meta?: LogMetadata): void => { + calls.info.push([msg, meta]); + }, + debug: (msg: string, meta?: LogMetadata): void => { + calls.debug.push([msg, meta]); + }, + calls, + }; + + return spyLogger; +} + +/** + * Creates a mock interface provider for testing + * Returns real ethers Interface objects for the specified contracts + */ +export function createMockInterfaceProvider(interfaces: { + TokenPool?: Interface; + TokenPoolFactory?: Interface; + BurnMintERC20?: Interface; +}): IInterfaceProvider { + return { + getTokenPoolInterface(): Interface { + if (!interfaces.TokenPool) { + throw new Error('No mock interface provided for TokenPool'); + } + return interfaces.TokenPool; + }, + getTokenPoolFactoryInterface(): Interface { + if (!interfaces.TokenPoolFactory) { + throw new Error('No mock interface provided for TokenPoolFactory'); + } + return interfaces.TokenPoolFactory; + }, + getFactoryBurnMintERC20Interface(): Interface { + if (!interfaces.BurnMintERC20) { + throw new Error('No mock interface provided for BurnMintERC20'); + } + return interfaces.BurnMintERC20; + }, + }; +} + +/** + * Creates a mock address computer for testing + * @param predictedAddress - The address to return from computeCreate2Address + */ +export function createMockAddressComputer(predictedAddress: string): IAddressComputer { + return { + computeCreate2Address(): string { + return predictedAddress; + }, + }; +} diff --git a/src/test/helpers/testHelpers.ts b/src/test/helpers/testHelpers.ts new file mode 100644 index 0000000..d9293ee --- /dev/null +++ b/src/test/helpers/testHelpers.ts @@ -0,0 +1,96 @@ +/** + * Test helper utilities + * Common utility functions for testing + */ + +import { SafeTransactionDataBase } from '../../types/safe'; + +/** + * Validates that a transaction has the expected base structure + */ +export function expectValidTransaction(tx: SafeTransactionDataBase): void { + expect(tx).toHaveProperty('to'); + expect(tx).toHaveProperty('value'); + expect(tx).toHaveProperty('data'); + expect(tx).toHaveProperty('operation'); + expect(typeof tx.to).toBe('string'); + expect(typeof tx.value).toBe('string'); + expect(typeof tx.data).toBe('string'); + expect(typeof tx.operation).toBe('number'); +} + +/** + * Validates that calldata is properly formatted hex string + */ +export function expectValidCalldata(data: string): void { + expect(data).toMatch(/^0x[a-fA-F0-9]+$/); + expect(data.length).toBeGreaterThan(10); // At least function selector + some data +} + +/** + * Validates that an address is a valid Ethereum address + */ +export function expectValidAddress(address: string): void { + expect(address).toMatch(/^0x[a-fA-F0-9]{40}$/); +} + +/** + * Type guard to check if value has the required Safe JSON structure + */ +function isSafeJsonLike(value: unknown): value is { + version: unknown; + chainId: unknown; + meta: unknown; + transactions: unknown; +} { + return ( + typeof value === 'object' && + value !== null && + 'version' in value && + 'chainId' in value && + 'meta' in value && + 'transactions' in value + ); +} + +/** + * Validates that Safe JSON has the required structure + */ +export function expectValidSafeJson(json: unknown): void { + expect(json).toHaveProperty('version'); + expect(json).toHaveProperty('chainId'); + expect(json).toHaveProperty('meta'); + expect(json).toHaveProperty('transactions'); + + if (isSafeJsonLike(json)) { + expect(Array.isArray(json.transactions)).toBe(true); + if (Array.isArray(json.transactions)) { + expect(json.transactions.length).toBeGreaterThan(0); + } + } +} + +/** + * Creates a matcher for error messages + */ +export function expectToThrowError(fn: () => void, expectedMessage: string | RegExp): void { + expect(fn).toThrow(expectedMessage); +} + +/** + * Validates that a string is a valid bytes32 hex string + */ +export function expectValidBytes32(bytes: string): void { + expect(bytes).toMatch(/^0x[a-fA-F0-9]{64}$/); +} + +/** + * Helper to validate transaction array structure + */ +export function expectValidTransactionArray(transactions: SafeTransactionDataBase[]): void { + expect(Array.isArray(transactions)).toBe(true); + expect(transactions.length).toBeGreaterThan(0); + transactions.forEach((tx) => { + expectValidTransaction(tx); + }); +} diff --git a/src/test/services/InterfaceProvider.test.ts b/src/test/services/InterfaceProvider.test.ts new file mode 100644 index 0000000..5259999 --- /dev/null +++ b/src/test/services/InterfaceProvider.test.ts @@ -0,0 +1,249 @@ +/** + * Tests for InterfaceProvider + * Covers interface caching and retrieval + */ + +import { createInterfaceProvider } from '../../services/InterfaceProvider'; +import { + TokenPool__factory, + TokenPoolFactory__factory, + FactoryBurnMintERC20__factory, +} from '../../typechain'; + +describe('InterfaceProvider', () => { + describe('getTokenPoolInterface', () => { + it('should return TokenPool interface', () => { + const provider = createInterfaceProvider(); + const tokenPoolInterface = provider.getTokenPoolInterface(); + + expect(tokenPoolInterface).toBeDefined(); + expect(tokenPoolInterface.fragments.length).toBeGreaterThan(0); + }); + + it('should cache and return same interface instance on subsequent calls', () => { + const provider = createInterfaceProvider(); + const firstCall = provider.getTokenPoolInterface(); + const secondCall = provider.getTokenPoolInterface(); + + expect(firstCall).toBe(secondCall); + }); + + it('should have applyChainUpdates function', () => { + const provider = createInterfaceProvider(); + const tokenPoolInterface = provider.getTokenPoolInterface(); + + const fragment = tokenPoolInterface.getFunction('applyChainUpdates'); + expect(fragment).toBeDefined(); + expect(fragment?.name).toBe('applyChainUpdates'); + }); + + it('should have setChainRateLimiterConfig function', () => { + const provider = createInterfaceProvider(); + const tokenPoolInterface = provider.getTokenPoolInterface(); + + const fragment = tokenPoolInterface.getFunction('setChainRateLimiterConfig'); + expect(fragment).toBeDefined(); + }); + + it('should have applyAllowListUpdates function', () => { + const provider = createInterfaceProvider(); + const tokenPoolInterface = provider.getTokenPoolInterface(); + + const fragment = tokenPoolInterface.getFunction('applyAllowListUpdates'); + expect(fragment).toBeDefined(); + }); + }); + + describe('getTokenPoolFactoryInterface', () => { + it('should return TokenPoolFactory interface', () => { + const provider = createInterfaceProvider(); + const factoryInterface = provider.getTokenPoolFactoryInterface(); + + expect(factoryInterface).toBeDefined(); + expect(factoryInterface.fragments.length).toBeGreaterThan(0); + }); + + it('should cache and return same interface instance on subsequent calls', () => { + const provider = createInterfaceProvider(); + const firstCall = provider.getTokenPoolFactoryInterface(); + const secondCall = provider.getTokenPoolFactoryInterface(); + + expect(firstCall).toBe(secondCall); + }); + + it('should have deployTokenAndTokenPool function', () => { + const provider = createInterfaceProvider(); + const factoryInterface = provider.getTokenPoolFactoryInterface(); + + const fragment = factoryInterface.getFunction('deployTokenAndTokenPool'); + expect(fragment).toBeDefined(); + expect(fragment?.name).toBe('deployTokenAndTokenPool'); + }); + + it('should have deployTokenPoolWithExistingToken function', () => { + const provider = createInterfaceProvider(); + const factoryInterface = provider.getTokenPoolFactoryInterface(); + + const fragment = factoryInterface.getFunction('deployTokenPoolWithExistingToken'); + expect(fragment).toBeDefined(); + }); + }); + + describe('getFactoryBurnMintERC20Interface', () => { + it('should return FactoryBurnMintERC20 interface', () => { + const provider = createInterfaceProvider(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + expect(tokenInterface).toBeDefined(); + expect(tokenInterface.fragments.length).toBeGreaterThan(0); + }); + + it('should cache and return same interface instance on subsequent calls', () => { + const provider = createInterfaceProvider(); + const firstCall = provider.getFactoryBurnMintERC20Interface(); + const secondCall = provider.getFactoryBurnMintERC20Interface(); + + expect(firstCall).toBe(secondCall); + }); + + it('should have mint function', () => { + const provider = createInterfaceProvider(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + const fragment = tokenInterface.getFunction('mint'); + expect(fragment).toBeDefined(); + expect(fragment?.name).toBe('mint'); + }); + + it('should have grantMintRole function', () => { + const provider = createInterfaceProvider(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + const fragment = tokenInterface.getFunction('grantMintRole'); + expect(fragment).toBeDefined(); + }); + + it('should have grantBurnRole function', () => { + const provider = createInterfaceProvider(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + const fragment = tokenInterface.getFunction('grantBurnRole'); + expect(fragment).toBeDefined(); + }); + + it('should have grantMintAndBurnRoles function', () => { + const provider = createInterfaceProvider(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + const fragment = tokenInterface.getFunction('grantMintAndBurnRoles'); + expect(fragment).toBeDefined(); + }); + + it('should have revokeMintRole function', () => { + const provider = createInterfaceProvider(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + const fragment = tokenInterface.getFunction('revokeMintRole'); + expect(fragment).toBeDefined(); + }); + + it('should have revokeBurnRole function', () => { + const provider = createInterfaceProvider(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + const fragment = tokenInterface.getFunction('revokeBurnRole'); + expect(fragment).toBeDefined(); + }); + }); + + describe('Cache behavior', () => { + it('should maintain separate caches for different interfaces', () => { + const provider = createInterfaceProvider(); + + const tokenPoolInterface = provider.getTokenPoolInterface(); + const factoryInterface = provider.getTokenPoolFactoryInterface(); + const tokenInterface = provider.getFactoryBurnMintERC20Interface(); + + // All should be different instances + expect(tokenPoolInterface).not.toBe(factoryInterface); + expect(tokenPoolInterface).not.toBe(tokenInterface); + expect(factoryInterface).not.toBe(tokenInterface); + }); + + it('should not share cache between different provider instances', () => { + const provider1 = createInterfaceProvider(); + const provider2 = createInterfaceProvider(); + + const interface1 = provider1.getTokenPoolInterface(); + const interface2 = provider2.getTokenPoolInterface(); + + // Different provider instances should create separate interface instances + // (even though they represent the same contract) + expect(interface1).not.toBe(interface2); + }); + + it('should cache all three interfaces independently', () => { + const provider = createInterfaceProvider(); + + // Get all interfaces + const tokenPool1 = provider.getTokenPoolInterface(); + const factory1 = provider.getTokenPoolFactoryInterface(); + const token1 = provider.getFactoryBurnMintERC20Interface(); + + // Get them again + const tokenPool2 = provider.getTokenPoolInterface(); + const factory2 = provider.getTokenPoolFactoryInterface(); + const token2 = provider.getFactoryBurnMintERC20Interface(); + + // Each should be cached + expect(tokenPool1).toBe(tokenPool2); + expect(factory1).toBe(factory2); + expect(token1).toBe(token2); + }); + }); + + describe('Interface compatibility', () => { + it('TokenPool interface should match factory-created interface', () => { + const provider = createInterfaceProvider(); + const providerInterface = provider.getTokenPoolInterface(); + const factoryInterface = TokenPool__factory.createInterface(); + + // Should have same function count + expect(providerInterface.fragments.length).toBe(factoryInterface.fragments.length); + }); + + it('TokenPoolFactory interface should match factory-created interface', () => { + const provider = createInterfaceProvider(); + const providerInterface = provider.getTokenPoolFactoryInterface(); + const factoryInterface = TokenPoolFactory__factory.createInterface(); + + expect(providerInterface.fragments.length).toBe(factoryInterface.fragments.length); + }); + + it('FactoryBurnMintERC20 interface should match factory-created interface', () => { + const provider = createInterfaceProvider(); + const providerInterface = provider.getFactoryBurnMintERC20Interface(); + const factoryInterface = FactoryBurnMintERC20__factory.createInterface(); + + expect(providerInterface.fragments.length).toBe(factoryInterface.fragments.length); + }); + }); + + describe('Multiple calls performance', () => { + it('should handle multiple sequential calls efficiently', () => { + const provider = createInterfaceProvider(); + + // Call each interface method multiple times + for (let i = 0; i < 10; i++) { + provider.getTokenPoolInterface(); + provider.getTokenPoolFactoryInterface(); + provider.getFactoryBurnMintERC20Interface(); + } + + // Verify cache is working (instances should be same) + const tokenPool1 = provider.getTokenPoolInterface(); + const tokenPool2 = provider.getTokenPoolInterface(); + expect(tokenPool1).toBe(tokenPool2); + }); + }); +}); diff --git a/src/test/services/TransactionService.test.ts b/src/test/services/TransactionService.test.ts new file mode 100644 index 0000000..c9af95e --- /dev/null +++ b/src/test/services/TransactionService.test.ts @@ -0,0 +1,466 @@ +/** + * Tests for TransactionService + * Covers transaction generation and formatting coordination + */ + +import { TransactionService, createTransactionService } from '../../services/TransactionService'; +import { SafeOperationType } from '../../types/safe'; +import type { ChainUpdateGenerator } from '../../generators/chainUpdateCalldata'; +import type { TokenDeploymentGenerator } from '../../generators/tokenDeployment'; +import type { PoolDeploymentGenerator } from '../../generators/poolDeployment'; +import type { TokenMintGenerator } from '../../generators/tokenMint'; +import type { RoleManagementGenerator } from '../../generators/roleManagement'; +import type { AllowListUpdatesGenerator } from '../../generators/allowListUpdates'; +import type { RateLimiterConfigGenerator } from '../../generators/rateLimiterConfig'; +import type { ChainUpdateFormatter } from '../../formatters/chainUpdateFormatter'; +import type { TokenDeploymentFormatter } from '../../formatters/tokenDeploymentFormatter'; +import type { PoolDeploymentFormatter } from '../../formatters/poolDeploymentFormatter'; +import type { MintFormatter } from '../../formatters/mintFormatter'; +import type { RoleManagementFormatter } from '../../formatters/roleManagementFormatter'; +import type { AllowListFormatter } from '../../formatters/allowListFormatter'; +import type { RateLimiterFormatter } from '../../formatters/rateLimiterFormatter'; +import type { AcceptOwnershipGenerator } from '../../generators/acceptOwnership'; +import type { AcceptOwnershipFormatter } from '../../formatters/acceptOwnershipFormatter'; + +describe('TransactionService', () => { + let service: TransactionService; + + // Mock generators - properly typed + const mockChainUpdateGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + const mockTokenDeploymentGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + const mockPoolDeploymentGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + const mockTokenMintGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + const mockRoleManagementGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + const mockAllowListUpdatesGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + const mockRateLimiterConfigGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + // Mock formatters - properly typed + const mockChainUpdateFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockTokenDeploymentFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockPoolDeploymentFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockMintFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockRoleManagementFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockAllowListFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockRateLimiterFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockAcceptOwnershipGenerator: jest.Mocked = { + generate: jest.fn(), + }; + + const mockAcceptOwnershipFormatter: jest.Mocked = { + format: jest.fn(), + }; + + const mockTransaction = { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + contractMethod: { + inputs: [], + name: 'testMethod', + payable: false, + }, + contractInputsValues: null, + }; + + const mockSafeJson = { + version: '1.0', + chainId: '11155111', + createdAt: Date.now(), + meta: { + name: 'Test', + description: 'Test transaction', + txBuilderVersion: '1.16.0', + createdFromSafeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + createdFromOwnerAddress: '0x0000000000000000000000000000000000000000', + }, + transactions: [mockTransaction], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + service = createTransactionService({ + chainUpdateGenerator: mockChainUpdateGenerator, + tokenDeploymentGenerator: mockTokenDeploymentGenerator, + poolDeploymentGenerator: mockPoolDeploymentGenerator, + tokenMintGenerator: mockTokenMintGenerator, + roleManagementGenerator: mockRoleManagementGenerator, + allowListUpdatesGenerator: mockAllowListUpdatesGenerator, + rateLimiterConfigGenerator: mockRateLimiterConfigGenerator, + acceptOwnershipGenerator: mockAcceptOwnershipGenerator, + chainUpdateFormatter: mockChainUpdateFormatter, + tokenDeploymentFormatter: mockTokenDeploymentFormatter, + poolDeploymentFormatter: mockPoolDeploymentFormatter, + mintFormatter: mockMintFormatter, + roleManagementFormatter: mockRoleManagementFormatter, + allowListFormatter: mockAllowListFormatter, + rateLimiterFormatter: mockRateLimiterFormatter, + acceptOwnershipFormatter: mockAcceptOwnershipFormatter, + }); + }); + + describe('generateChainUpdate', () => { + it('should generate transaction without Safe JSON', async () => { + mockChainUpdateGenerator.generate.mockResolvedValue(mockTransaction); + + const result = await service.generateChainUpdate( + '[]', + '0x779877A7B0D9E8603169DdbD7836e478b4624789', + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toBeNull(); + expect(mockChainUpdateGenerator.generate).toHaveBeenCalledWith('[]'); + expect(mockChainUpdateFormatter.format).not.toHaveBeenCalled(); + }); + + it('should generate transaction with Safe JSON when metadata provided', async () => { + mockChainUpdateGenerator.generate.mockResolvedValue(mockTransaction); + mockChainUpdateFormatter.format.mockReturnValue(mockSafeJson); + + const metadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + tokenPoolAddress: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + }; + + const result = await service.generateChainUpdate( + '[]', + '0x779877A7B0D9E8603169DdbD7836e478b4624789', + metadata, + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toEqual(mockSafeJson); + expect(mockChainUpdateFormatter.format).toHaveBeenCalledWith(mockTransaction, metadata); + }); + }); + + describe('generateTokenDeployment', () => { + const inputJson = JSON.stringify({ + name: 'Test Token', + symbol: 'TST', + decimals: 18, + maxSupply: '1000000', + preMint: '0', + remoteTokenPools: [], + }); + + it('should generate transaction without Safe JSON', async () => { + mockTokenDeploymentGenerator.generate.mockResolvedValue(mockTransaction); + + const result = await service.generateTokenDeployment( + inputJson, + '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toBeNull(); + expect(mockTokenDeploymentFormatter.format).not.toHaveBeenCalled(); + }); + + it('should generate transaction with Safe JSON when metadata provided', async () => { + mockTokenDeploymentGenerator.generate.mockResolvedValue(mockTransaction); + mockTokenDeploymentFormatter.format.mockReturnValue(mockSafeJson); + + const metadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + }; + + const result = await service.generateTokenDeployment( + inputJson, + '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + metadata, + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toEqual(mockSafeJson); + expect(mockTokenDeploymentFormatter.format).toHaveBeenCalled(); + }); + }); + + describe('generatePoolDeployment', () => { + const inputJson = JSON.stringify({ + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'BurnMintTokenPool', + remoteTokenPools: [], + }); + + it('should generate transaction without Safe JSON', async () => { + mockPoolDeploymentGenerator.generate.mockResolvedValue(mockTransaction); + + const result = await service.generatePoolDeployment( + inputJson, + '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + '0x0000000000000000000000000000000000000000000000000000000000000001', + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toBeNull(); + }); + + it('should generate transaction with Safe JSON when metadata provided', async () => { + mockPoolDeploymentGenerator.generate.mockResolvedValue(mockTransaction); + mockPoolDeploymentFormatter.format.mockReturnValue(mockSafeJson); + + const metadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + }; + + const result = await service.generatePoolDeployment( + inputJson, + '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + '0x0000000000000000000000000000000000000000000000000000000000000001', + metadata, + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toEqual(mockSafeJson); + }); + }); + + describe('generateMint', () => { + const inputJson = JSON.stringify({ + receiver: '0x1234567890123456789012345678901234567890', + amount: '1000000000000000000', + }); + + it('should generate transaction without Safe JSON', async () => { + mockTokenMintGenerator.generate.mockResolvedValue(mockTransaction); + + const result = await service.generateMint( + inputJson, + '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toBeNull(); + }); + + it('should generate transaction with Safe JSON when metadata provided', async () => { + mockTokenMintGenerator.generate.mockResolvedValue(mockTransaction); + mockMintFormatter.format.mockReturnValue(mockSafeJson); + + const metadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + tokenAddress: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + }; + + const result = await service.generateMint( + inputJson, + '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + metadata, + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toEqual(mockSafeJson); + }); + }); + + describe('generateRoleManagement', () => { + const inputJson = JSON.stringify({ + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + roleType: 'both', + action: 'grant', + }); + + it('should generate transactions without Safe JSON', async () => { + mockRoleManagementGenerator.generate.mockResolvedValue({ + transactions: [mockTransaction], + functionNames: ['grantMintAndBurnRoles' as const], + }); + + const result = await service.generateRoleManagement( + inputJson, + '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + ); + + expect(result.transactions).toEqual([mockTransaction]); + expect(result.safeJson).toBeNull(); + }); + + it('should generate transactions with Safe JSON when metadata provided', async () => { + mockRoleManagementGenerator.generate.mockResolvedValue({ + transactions: [mockTransaction], + functionNames: ['grantMintAndBurnRoles' as const], + }); + mockRoleManagementFormatter.format.mockReturnValue(mockSafeJson); + + const metadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + tokenAddress: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + }; + + const result = await service.generateRoleManagement( + inputJson, + '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + metadata, + ); + + expect(result.transactions).toEqual([mockTransaction]); + expect(result.safeJson).toEqual(mockSafeJson); + }); + }); + + describe('generateAllowListUpdates', () => { + const inputJson = JSON.stringify({ + adds: ['0x1234567890123456789012345678901234567890'], + removes: [], + }); + + it('should generate transaction without Safe JSON', async () => { + mockAllowListUpdatesGenerator.generate.mockResolvedValue(mockTransaction); + + const result = await service.generateAllowListUpdates( + inputJson, + '0x779877A7B0D9E8603169DdbD7836e478b4624789', + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toBeNull(); + }); + + it('should generate transaction with Safe JSON when metadata provided', async () => { + mockAllowListUpdatesGenerator.generate.mockResolvedValue(mockTransaction); + mockAllowListFormatter.format.mockReturnValue(mockSafeJson); + + const metadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + tokenPoolAddress: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + }; + + const result = await service.generateAllowListUpdates( + inputJson, + '0x779877A7B0D9E8603169DdbD7836e478b4624789', + metadata, + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toEqual(mockSafeJson); + }); + }); + + describe('generateRateLimiterConfig', () => { + const inputJson = JSON.stringify({ + remoteChainSelector: '16015286601757825753', + outboundConfig: { isEnabled: true, capacity: '1000', rate: '100' }, + inboundConfig: { isEnabled: true, capacity: '2000', rate: '200' }, + }); + + it('should generate transaction without Safe JSON', async () => { + mockRateLimiterConfigGenerator.generate.mockResolvedValue(mockTransaction); + + const result = await service.generateRateLimiterConfig( + inputJson, + '0x779877A7B0D9E8603169DdbD7836e478b4624789', + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toBeNull(); + }); + + it('should generate transaction with Safe JSON when metadata provided', async () => { + mockRateLimiterConfigGenerator.generate.mockResolvedValue(mockTransaction); + mockRateLimiterFormatter.format.mockReturnValue(mockSafeJson); + + const metadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + tokenPoolAddress: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + }; + + const result = await service.generateRateLimiterConfig( + inputJson, + '0x779877A7B0D9E8603169DdbD7836e478b4624789', + metadata, + ); + + expect(result.transaction).toEqual(mockTransaction); + expect(result.safeJson).toEqual(mockSafeJson); + }); + }); + + describe('createTransactionService', () => { + it('should create a TransactionService instance', () => { + const service = createTransactionService({ + chainUpdateGenerator: mockChainUpdateGenerator, + tokenDeploymentGenerator: mockTokenDeploymentGenerator, + poolDeploymentGenerator: mockPoolDeploymentGenerator, + tokenMintGenerator: mockTokenMintGenerator, + roleManagementGenerator: mockRoleManagementGenerator, + allowListUpdatesGenerator: mockAllowListUpdatesGenerator, + rateLimiterConfigGenerator: mockRateLimiterConfigGenerator, + acceptOwnershipGenerator: mockAcceptOwnershipGenerator, + chainUpdateFormatter: mockChainUpdateFormatter, + tokenDeploymentFormatter: mockTokenDeploymentFormatter, + poolDeploymentFormatter: mockPoolDeploymentFormatter, + mintFormatter: mockMintFormatter, + roleManagementFormatter: mockRoleManagementFormatter, + allowListFormatter: mockAllowListFormatter, + rateLimiterFormatter: mockRateLimiterFormatter, + acceptOwnershipFormatter: mockAcceptOwnershipFormatter, + }); + + expect(service).toBeInstanceOf(TransactionService); + }); + }); +}); diff --git a/src/test/types/typeGuards.test.ts b/src/test/types/typeGuards.test.ts new file mode 100644 index 0000000..f905599 --- /dev/null +++ b/src/test/types/typeGuards.test.ts @@ -0,0 +1,374 @@ +/** + * Tests for type guards + * Covers runtime type checking for all type predicates + */ + +import { + isObject, + parseJSON, + isTokenDeploymentParams, + isPoolDeploymentParams, + isMintParams, + isAllowListUpdatesInput, + isSetChainRateLimiterConfigInput, + isRoleManagementParams, +} from '../../types/typeGuards'; + +describe('isObject', () => { + it('should return true for plain objects', () => { + expect(isObject({})).toBe(true); + expect(isObject({ foo: 'bar' })).toBe(true); + expect(isObject({ nested: { obj: true } })).toBe(true); + }); + + it('should return false for null', () => { + expect(isObject(null)).toBe(false); + }); + + it('should return false for arrays', () => { + expect(isObject([])).toBe(false); + expect(isObject([1, 2, 3])).toBe(false); + }); + + it('should return false for primitives', () => { + expect(isObject('string')).toBe(false); + expect(isObject(123)).toBe(false); + expect(isObject(true)).toBe(false); + expect(isObject(undefined)).toBe(false); + }); + + it('should return false for functions', () => { + expect(isObject(() => {})).toBe(false); + expect(isObject(function () {})).toBe(false); + }); +}); + +describe('parseJSON', () => { + it('should parse valid JSON and validate type', () => { + const json = '{"name":"test","symbol":"TST","decimals":18}'; + const result = parseJSON(json, isTokenDeploymentParams); + expect(result).toEqual({ name: 'test', symbol: 'TST', decimals: 18 }); + }); + + it('should throw error for invalid JSON structure', () => { + const json = '{"invalid":"structure"}'; + expect(() => parseJSON(json, isTokenDeploymentParams)).toThrow('Invalid JSON structure'); + }); + + it('should throw error for malformed JSON', () => { + const json = 'not valid json'; + expect(() => parseJSON(json, isTokenDeploymentParams)).toThrow(); + }); + + it('should work with different validators', () => { + const json = '{"receiver":"0x123","amount":"100"}'; + const result = parseJSON(json, isMintParams); + expect(result).toEqual({ receiver: '0x123', amount: '100' }); + }); +}); + +describe('isTokenDeploymentParams', () => { + it('should return true for valid TokenDeploymentParams', () => { + const valid = { + name: 'Test Token', + symbol: 'TST', + decimals: 18, + maxSupply: '1000000', + preMint: '0', + remoteTokenPools: [], + }; + expect(isTokenDeploymentParams(valid)).toBe(true); + }); + + it('should return true for minimal valid params', () => { + const valid = { + name: 'Test', + symbol: 'T', + decimals: 6, + }; + expect(isTokenDeploymentParams(valid)).toBe(true); + }); + + it('should return false for missing name', () => { + const invalid = { + symbol: 'TST', + decimals: 18, + }; + expect(isTokenDeploymentParams(invalid)).toBe(false); + }); + + it('should return false for missing symbol', () => { + const invalid = { + name: 'Test', + decimals: 18, + }; + expect(isTokenDeploymentParams(invalid)).toBe(false); + }); + + it('should return false for missing decimals', () => { + const invalid = { + name: 'Test', + symbol: 'TST', + }; + expect(isTokenDeploymentParams(invalid)).toBe(false); + }); + + it('should return false for wrong types', () => { + expect(isTokenDeploymentParams({ name: 123, symbol: 'TST', decimals: 18 })).toBe(false); + expect(isTokenDeploymentParams({ name: 'Test', symbol: 123, decimals: 18 })).toBe(false); + expect(isTokenDeploymentParams({ name: 'Test', symbol: 'TST', decimals: '18' })).toBe(false); + }); + + it('should return false for non-objects', () => { + expect(isTokenDeploymentParams(null)).toBe(false); + expect(isTokenDeploymentParams('string')).toBe(false); + expect(isTokenDeploymentParams([])).toBe(false); + }); +}); + +describe('isPoolDeploymentParams', () => { + it('should return true for valid PoolDeploymentParams', () => { + const valid = { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + decimals: 18, + poolType: 'BurnMintTokenPool', + remoteTokenPools: [], + }; + expect(isPoolDeploymentParams(valid)).toBe(true); + }); + + it('should return true for minimal valid params', () => { + const valid = { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + poolType: 'LockReleaseTokenPool', + }; + expect(isPoolDeploymentParams(valid)).toBe(true); + }); + + it('should return false for missing token', () => { + const invalid = { + poolType: 'BurnMintTokenPool', + }; + expect(isPoolDeploymentParams(invalid)).toBe(false); + }); + + it('should return false for missing poolType', () => { + const invalid = { + token: '0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05', + }; + expect(isPoolDeploymentParams(invalid)).toBe(false); + }); + + it('should return false for wrong types', () => { + expect(isPoolDeploymentParams({ token: 123, poolType: 'BurnMintTokenPool' })).toBe(false); + expect(isPoolDeploymentParams({ token: '0x123', poolType: 123 })).toBe(false); + }); + + it('should return false for non-objects', () => { + expect(isPoolDeploymentParams(null)).toBe(false); + expect(isPoolDeploymentParams([])).toBe(false); + }); +}); + +describe('isMintParams', () => { + it('should return true for valid MintParams', () => { + const valid = { + receiver: '0x1234567890123456789012345678901234567890', + amount: '1000000000000000000', + }; + expect(isMintParams(valid)).toBe(true); + }); + + it('should return false for missing receiver', () => { + const invalid = { + amount: '1000', + }; + expect(isMintParams(invalid)).toBe(false); + }); + + it('should return false for missing amount', () => { + const invalid = { + receiver: '0x123', + }; + expect(isMintParams(invalid)).toBe(false); + }); + + it('should return false for wrong types', () => { + expect(isMintParams({ receiver: 123, amount: '1000' })).toBe(false); + expect(isMintParams({ receiver: '0x123', amount: 1000 })).toBe(false); + }); + + it('should return false for non-objects', () => { + expect(isMintParams(null)).toBe(false); + expect(isMintParams('string')).toBe(false); + }); +}); + +describe('isAllowListUpdatesInput', () => { + it('should return true for valid input with both arrays', () => { + const valid = { + adds: ['0x123', '0x456'], + removes: ['0x789'], + }; + expect(isAllowListUpdatesInput(valid)).toBe(true); + }); + + it('should return true for empty arrays', () => { + const valid = { + adds: [], + removes: [], + }; + expect(isAllowListUpdatesInput(valid)).toBe(true); + }); + + it('should return true when adds is undefined', () => { + const valid = { + removes: ['0x123'], + }; + expect(isAllowListUpdatesInput(valid)).toBe(true); + }); + + it('should return true when removes is undefined', () => { + const valid = { + adds: ['0x123'], + }; + expect(isAllowListUpdatesInput(valid)).toBe(true); + }); + + it('should return true when both are undefined', () => { + const valid = {}; + expect(isAllowListUpdatesInput(valid)).toBe(true); + }); + + it('should return false when adds is not an array', () => { + const invalid = { + adds: 'not-array', + removes: [], + }; + expect(isAllowListUpdatesInput(invalid)).toBe(false); + }); + + it('should return false when removes is not an array', () => { + const invalid = { + adds: [], + removes: 'not-array', + }; + expect(isAllowListUpdatesInput(invalid)).toBe(false); + }); + + it('should return false for non-objects', () => { + expect(isAllowListUpdatesInput(null)).toBe(false); + expect(isAllowListUpdatesInput([])).toBe(false); + }); +}); + +describe('isSetChainRateLimiterConfigInput', () => { + it('should return true for valid input', () => { + const valid = { + remoteChainSelector: '16015286601757825753', + outboundConfig: { isEnabled: true, capacity: '1000', rate: '100' }, + inboundConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }; + expect(isSetChainRateLimiterConfigInput(valid)).toBe(true); + }); + + it('should return false for missing remoteChainSelector', () => { + const invalid = { + outboundConfig: { isEnabled: true }, + inboundConfig: { isEnabled: false }, + }; + expect(isSetChainRateLimiterConfigInput(invalid)).toBe(false); + }); + + it('should return false for missing outboundConfig', () => { + const invalid = { + remoteChainSelector: '123', + inboundConfig: { isEnabled: false }, + }; + expect(isSetChainRateLimiterConfigInput(invalid)).toBe(false); + }); + + it('should return false for missing inboundConfig', () => { + const invalid = { + remoteChainSelector: '123', + outboundConfig: { isEnabled: true }, + }; + expect(isSetChainRateLimiterConfigInput(invalid)).toBe(false); + }); + + it('should return false when outboundConfig is not an object', () => { + const invalid = { + remoteChainSelector: '123', + outboundConfig: 'not-object', + inboundConfig: {}, + }; + expect(isSetChainRateLimiterConfigInput(invalid)).toBe(false); + }); + + it('should return false when inboundConfig is not an object', () => { + const invalid = { + remoteChainSelector: '123', + outboundConfig: {}, + inboundConfig: 'not-object', + }; + expect(isSetChainRateLimiterConfigInput(invalid)).toBe(false); + }); + + it('should return false for wrong types', () => { + expect( + isSetChainRateLimiterConfigInput({ + remoteChainSelector: 123, + outboundConfig: {}, + inboundConfig: {}, + }), + ).toBe(false); + }); + + it('should return false for non-objects', () => { + expect(isSetChainRateLimiterConfigInput(null)).toBe(false); + expect(isSetChainRateLimiterConfigInput([])).toBe(false); + }); +}); + +describe('isRoleManagementParams', () => { + it('should return true for valid RoleManagementParams', () => { + const valid = { + pool: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + roleType: 'both', + action: 'grant', + }; + expect(isRoleManagementParams(valid)).toBe(true); + }); + + it('should return true for minimal valid params', () => { + const valid = { + pool: '0x123', + roleType: 'mint', + }; + expect(isRoleManagementParams(valid)).toBe(true); + }); + + it('should return false for missing pool', () => { + const invalid = { + roleType: 'mint', + }; + expect(isRoleManagementParams(invalid)).toBe(false); + }); + + it('should return false for missing roleType', () => { + const invalid = { + pool: '0x123', + }; + expect(isRoleManagementParams(invalid)).toBe(false); + }); + + it('should return false for wrong types', () => { + expect(isRoleManagementParams({ pool: 123, roleType: 'mint' })).toBe(false); + expect(isRoleManagementParams({ pool: '0x123', roleType: 123 })).toBe(false); + }); + + it('should return false for non-objects', () => { + expect(isRoleManagementParams(null)).toBe(false); + expect(isRoleManagementParams('string')).toBe(false); + }); +}); diff --git a/src/test/unit.test.ts b/src/test/unit.test.ts index d5b777a..2acea56 100644 --- a/src/test/unit.test.ts +++ b/src/test/unit.test.ts @@ -1,9 +1,7 @@ -import { - convertToContractFormat, - generateChainUpdateTransaction, -} from '../generators/chainUpdateCalldata'; +import { convertToContractFormat } from '../generators/chainUpdateCalldata'; import { SafeOperationType } from '../types/safe'; import { ChainType, ChainUpdateInput } from '../types/chainUpdate'; +import { getServiceContainer } from '../services'; describe('convertToContractFormat', () => { const baseChainUpdateStub: Partial = { @@ -118,9 +116,10 @@ describe('convertToContractFormat', () => { }); }); -describe('generateChainUpdateTransaction', () => { +describe('chainUpdateGenerator', () => { describe('EVM Chain Type', () => { it('should generate transaction for EVM chain updates', async () => { + const container = getServiceContainer(); const inputJson = JSON.stringify([ [], // Chain selectors to remove. See `chainUpdatesInputSchema` [ @@ -146,7 +145,7 @@ describe('generateChainUpdateTransaction', () => { ], ]); - const result = await generateChainUpdateTransaction(inputJson); + const result = await container.chainUpdateGenerator.generate(inputJson); expect(result).toHaveProperty('to', ''); expect(result).toHaveProperty('value', '0'); diff --git a/src/test/utils/addressComputer.test.ts b/src/test/utils/addressComputer.test.ts new file mode 100644 index 0000000..ec73f9a --- /dev/null +++ b/src/test/utils/addressComputer.test.ts @@ -0,0 +1,328 @@ +/** + * Tests for addressComputer utility + * Covers CREATE2 address computation and salt modification + */ + +import { ethers } from 'ethers'; +import { computeModifiedSalt, createAddressComputer } from '../../utils/addressComputer'; +import { createMockLogger, createSpyLogger } from '../helpers'; + +describe('computeModifiedSalt', () => { + const validSalt = '0x0000000000000000000000000000000000000000000000000000000000000001'; + const validSender = '0x5419c6d83473d1c653e7b51e8568fafedce94f01'; + + it('should compute modified salt correctly', () => { + const result = computeModifiedSalt(validSalt, validSender); + + // Result should be a valid bytes32 hex string + expect(result).toMatch(/^0x[a-f0-9]{64}$/i); + expect(result.length).toBe(66); // 0x + 64 hex chars + }); + + it('should produce different results for different salts', () => { + const salt1 = '0x0000000000000000000000000000000000000000000000000000000000000001'; + const salt2 = '0x0000000000000000000000000000000000000000000000000000000000000002'; + + const result1 = computeModifiedSalt(salt1, validSender); + const result2 = computeModifiedSalt(salt2, validSender); + + expect(result1).not.toBe(result2); + }); + + it('should produce different results for different senders', () => { + const sender1 = '0x5419c6d83473d1c653e7b51e8568fafedce94f01'; + const sender2 = '0x779877A7B0D9E8603169DdbD7836e478b4624789'; + + const result1 = computeModifiedSalt(validSalt, sender1); + const result2 = computeModifiedSalt(validSalt, sender2); + + expect(result1).not.toBe(result2); + }); + + it('should be deterministic for same inputs', () => { + const result1 = computeModifiedSalt(validSalt, validSender); + const result2 = computeModifiedSalt(validSalt, validSender); + + expect(result1).toBe(result2); + }); + + it('should throw error for invalid sender address', () => { + expect(() => { + computeModifiedSalt(validSalt, 'invalid-address'); + }).toThrow('Invalid sender address'); + }); + + it('should throw error for malformed sender address', () => { + expect(() => { + computeModifiedSalt(validSalt, '0xinvalid'); + }).toThrow('Invalid sender address'); + }); + + it('should handle zero address as sender', () => { + const zeroAddress = '0x0000000000000000000000000000000000000000'; + const result = computeModifiedSalt(validSalt, zeroAddress); + + expect(result).toMatch(/^0x[a-f0-9]{64}$/i); + }); + + it('should handle zero salt', () => { + const zeroSalt = '0x0000000000000000000000000000000000000000000000000000000000000000'; + const result = computeModifiedSalt(zeroSalt, validSender); + + expect(result).toMatch(/^0x[a-f0-9]{64}$/i); + }); + + it('should match ethers.js solidityPacked behavior', () => { + // Verify the computation matches ethers.js solidityPacked + keccak256 + const expected = ethers.keccak256( + ethers.solidityPacked(['bytes32', 'address'], [validSalt, validSender]), + ); + const result = computeModifiedSalt(validSalt, validSender); + + expect(result).toBe(expected); + }); +}); + +describe('createAddressComputer', () => { + let mockLogger: ReturnType; + let addressComputer: ReturnType; + + // Known CREATE2 deployment values for testing + const DEPLOYER = '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e'; + const SENDER = '0x5419c6d83473d1c653e7b51e8568fafedce94f01'; + const SALT = '0x0000000000000000000000000000000000000000000000000000000000000001'; + // Minimal valid EVM bytecode (just a STOP opcode) + const BYTECODE = '0x00'; + + beforeEach(() => { + mockLogger = createMockLogger(); + addressComputer = createAddressComputer(mockLogger); + }); + + describe('computeCreate2Address', () => { + it('should compute CREATE2 address correctly', () => { + const result = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, SENDER); + + // Result should be a valid Ethereum address + expect(ethers.isAddress(result)).toBe(true); + expect(result).toMatch(/^0x[a-fA-F0-9]{40}$/); + }); + + it('should be deterministic for same inputs', () => { + const result1 = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, SENDER); + const result2 = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, SENDER); + + expect(result1).toBe(result2); + }); + + it('should produce different addresses for different deployers', () => { + const deployer1 = '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e'; + const deployer2 = '0x779877A7B0D9E8603169DdbD7836e478b4624789'; + + const result1 = addressComputer.computeCreate2Address(deployer1, BYTECODE, SALT, SENDER); + const result2 = addressComputer.computeCreate2Address(deployer2, BYTECODE, SALT, SENDER); + + expect(result1).not.toBe(result2); + }); + + it('should produce different addresses for different bytecodes', () => { + const bytecode1 = '0x00'; + const bytecode2 = '0x60806040'; + + const result1 = addressComputer.computeCreate2Address(DEPLOYER, bytecode1, SALT, SENDER); + const result2 = addressComputer.computeCreate2Address(DEPLOYER, bytecode2, SALT, SENDER); + + expect(result1).not.toBe(result2); + }); + + it('should produce different addresses for different salts', () => { + const salt1 = '0x0000000000000000000000000000000000000000000000000000000000000001'; + const salt2 = '0x0000000000000000000000000000000000000000000000000000000000000002'; + + const result1 = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, salt1, SENDER); + const result2 = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, salt2, SENDER); + + expect(result1).not.toBe(result2); + }); + + it('should produce different addresses for different senders', () => { + const sender1 = '0x5419c6d83473d1c653e7b51e8568fafedce94f01'; + const sender2 = '0x779877A7B0D9E8603169DdbD7836e478b4624789'; + + const result1 = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, sender1); + const result2 = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, sender2); + + expect(result1).not.toBe(result2); + }); + + it('should throw error for invalid deployer address', () => { + expect(() => { + addressComputer.computeCreate2Address('invalid-address', BYTECODE, SALT, SENDER); + }).toThrow('Invalid deployer address'); + }); + + it('should throw error for invalid sender address', () => { + expect(() => { + addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, 'invalid-address'); + }).toThrow('Invalid sender address'); + }); + + it('should handle zero address as deployer', () => { + const zeroAddress = '0x0000000000000000000000000000000000000000'; + const result = addressComputer.computeCreate2Address(zeroAddress, BYTECODE, SALT, SENDER); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should handle zero address as sender', () => { + const zeroAddress = '0x0000000000000000000000000000000000000000'; + const result = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, zeroAddress); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should handle empty bytecode', () => { + const result = addressComputer.computeCreate2Address(DEPLOYER, '0x', SALT, SENDER); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should handle large bytecode', () => { + // Simulate a large contract bytecode (1KB) + const largeBytecode = '0x' + '60'.repeat(1024); + const result = addressComputer.computeCreate2Address(DEPLOYER, largeBytecode, SALT, SENDER); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should log address computation details', () => { + const spyLogger = createSpyLogger(); + const spyAddressComputer = createAddressComputer(spyLogger); + + spyAddressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, SENDER); + + // Verify logger.info was called with correct structure + expect(spyLogger.calls.info).toHaveLength(1); + expect(spyLogger.calls.info[0]?.[0]).toBe('Computed CREATE2 address'); + const logMeta = spyLogger.calls.info[0]?.[1] as + | { + deployer: string; + originalSalt: string; + sender: string; + modifiedSalt: string; + initCodeHash: string; + predictedAddress: string; + } + | undefined; + + expect(logMeta).toBeDefined(); + expect(logMeta).toMatchObject({ + deployer: DEPLOYER, + originalSalt: SALT, + sender: SENDER, + }); + + if (logMeta) { + expect(logMeta.modifiedSalt).toMatch(/^0x[a-f0-9]{64}$/i); + expect(logMeta.initCodeHash).toMatch(/^0x[a-f0-9]{64}$/i); + expect(logMeta.predictedAddress).toMatch(/^0x[a-fA-F0-9]{40}$/); + } + }); + + it('should match ethers.js getCreate2Address with modified salt', () => { + const modifiedSalt = computeModifiedSalt(SALT, SENDER); + const initCodeHash = ethers.keccak256(BYTECODE); + const expected = ethers.getCreate2Address(DEPLOYER, modifiedSalt, initCodeHash); + + const result = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, SALT, SENDER); + + expect(result).toBe(expected); + }); + + it('should handle checksummed addresses', () => { + // Test with checksummed addresses + const checksummedDeployer = ethers.getAddress(DEPLOYER); + const checksummedSender = ethers.getAddress(SENDER); + + const result = addressComputer.computeCreate2Address( + checksummedDeployer, + BYTECODE, + SALT, + checksummedSender, + ); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should handle lowercase addresses', () => { + const lowercaseDeployer = DEPLOYER.toLowerCase(); + const lowercaseSender = SENDER.toLowerCase(); + + const result = addressComputer.computeCreate2Address( + lowercaseDeployer, + BYTECODE, + SALT, + lowercaseSender, + ); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should produce same result regardless of address casing', () => { + const checksummedResult = addressComputer.computeCreate2Address( + ethers.getAddress(DEPLOYER), + BYTECODE, + SALT, + ethers.getAddress(SENDER), + ); + + const lowercaseResult = addressComputer.computeCreate2Address( + DEPLOYER.toLowerCase(), + BYTECODE, + SALT, + SENDER.toLowerCase(), + ); + + expect(checksummedResult.toLowerCase()).toBe(lowercaseResult.toLowerCase()); + }); + }); + + describe('Edge Cases', () => { + it('should handle maximum uint256 salt value', () => { + const maxSalt = '0x' + 'f'.repeat(64); + const result = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, maxSalt, SENDER); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should handle bytecode with constructor arguments', () => { + // Typical pattern: bytecode + encoded constructor args + const bytecodeWithArgs = + '0x60806040' + // Contract bytecode + '0000000000000000000000000000000000000000000000000000000000000020' + // Constructor arg + '000000000000000000000000000000000000000000000000000000000000000a'; // Constructor arg + + const result = addressComputer.computeCreate2Address( + DEPLOYER, + bytecodeWithArgs, + SALT, + SENDER, + ); + + expect(ethers.isAddress(result)).toBe(true); + }); + + it('should handle sequential salt values', () => { + const results = []; + for (let i = 0; i < 5; i++) { + const salt = ethers.zeroPadValue(ethers.toBeHex(i), 32); + const result = addressComputer.computeCreate2Address(DEPLOYER, BYTECODE, salt, SENDER); + results.push(result); + } + + // All addresses should be unique + const uniqueResults = new Set(results); + expect(uniqueResults.size).toBe(5); + }); + }); +}); diff --git a/src/test/utils/poolTypeConverter.test.ts b/src/test/utils/poolTypeConverter.test.ts new file mode 100644 index 0000000..98d859f --- /dev/null +++ b/src/test/utils/poolTypeConverter.test.ts @@ -0,0 +1,295 @@ +/** + * Tests for poolTypeConverter utility + * Covers conversion between PoolType enum and contract numeric values + */ + +import { poolTypeToNumber, numberToPoolType } from '../../utils/poolTypeConverter'; +import { PoolType } from '../../types/poolDeployment'; + +describe('poolTypeToNumber', () => { + it('should convert BurnMintTokenPool to 0', () => { + const result = poolTypeToNumber('BurnMintTokenPool'); + expect(result).toBe(0); + }); + + it('should convert LockReleaseTokenPool to 1', () => { + const result = poolTypeToNumber('LockReleaseTokenPool'); + expect(result).toBe(1); + }); + + it('should be consistent for multiple calls with same input', () => { + const result1 = poolTypeToNumber('BurnMintTokenPool'); + const result2 = poolTypeToNumber('BurnMintTokenPool'); + expect(result1).toBe(result2); + }); + + it('should handle PoolType type correctly', () => { + const poolType: PoolType = 'BurnMintTokenPool'; + const result = poolTypeToNumber(poolType); + expect(result).toBe(0); + }); + + it('should return different values for different pool types', () => { + const burnMintResult = poolTypeToNumber('BurnMintTokenPool'); + const lockReleaseResult = poolTypeToNumber('LockReleaseTokenPool'); + expect(burnMintResult).not.toBe(lockReleaseResult); + }); +}); + +describe('numberToPoolType', () => { + it('should convert 0 to BurnMintTokenPool', () => { + const result = numberToPoolType(0); + expect(result).toBe('BurnMintTokenPool'); + }); + + it('should convert 1 to LockReleaseTokenPool', () => { + const result = numberToPoolType(1); + expect(result).toBe('LockReleaseTokenPool'); + }); + + it('should throw error for invalid value 2', () => { + expect(() => numberToPoolType(2)).toThrow('Invalid pool type value: 2. Expected 0 or 1.'); + }); + + it('should throw error for invalid value -1', () => { + expect(() => numberToPoolType(-1)).toThrow('Invalid pool type value: -1. Expected 0 or 1.'); + }); + + it('should throw error for invalid value 999', () => { + expect(() => numberToPoolType(999)).toThrow('Invalid pool type value: 999. Expected 0 or 1.'); + }); + + it('should throw error for NaN', () => { + expect(() => numberToPoolType(NaN)).toThrow('Invalid pool type value: NaN. Expected 0 or 1.'); + }); + + it('should throw error for Infinity', () => { + expect(() => numberToPoolType(Infinity)).toThrow( + 'Invalid pool type value: Infinity. Expected 0 or 1.', + ); + }); + + it('should throw error for negative Infinity', () => { + expect(() => numberToPoolType(-Infinity)).toThrow( + 'Invalid pool type value: -Infinity. Expected 0 or 1.', + ); + }); + + it('should be consistent for multiple calls with same input', () => { + const result1 = numberToPoolType(0); + const result2 = numberToPoolType(0); + expect(result1).toBe(result2); + }); + + it('should return PoolType type', () => { + const result = numberToPoolType(0); + const poolType: PoolType = result; + expect(poolType).toBe('BurnMintTokenPool'); + }); + + it('should return different values for different numbers', () => { + const zeroResult = numberToPoolType(0); + const oneResult = numberToPoolType(1); + expect(zeroResult).not.toBe(oneResult); + }); +}); + +describe('Round-trip conversions', () => { + it('should convert BurnMintTokenPool to number and back', () => { + const poolType: PoolType = 'BurnMintTokenPool'; + const number = poolTypeToNumber(poolType); + const result = numberToPoolType(number); + expect(result).toBe(poolType); + }); + + it('should convert LockReleaseTokenPool to number and back', () => { + const poolType: PoolType = 'LockReleaseTokenPool'; + const number = poolTypeToNumber(poolType); + const result = numberToPoolType(number); + expect(result).toBe(poolType); + }); + + it('should convert 0 to PoolType and back', () => { + const number = 0; + const poolType = numberToPoolType(number); + const result = poolTypeToNumber(poolType); + expect(result).toBe(number); + }); + + it('should convert 1 to PoolType and back', () => { + const number = 1; + const poolType = numberToPoolType(number); + const result = poolTypeToNumber(poolType); + expect(result).toBe(number); + }); + + it('should maintain consistency across multiple conversions', () => { + const original: PoolType = 'BurnMintTokenPool'; + const num1 = poolTypeToNumber(original); + const poolType1 = numberToPoolType(num1); + const num2 = poolTypeToNumber(poolType1); + const poolType2 = numberToPoolType(num2); + + expect(poolType1).toBe(original); + expect(poolType2).toBe(original); + expect(num1).toBe(num2); + }); +}); + +describe('Contract compatibility', () => { + it('should match expected contract enum values for BurnMintTokenPool', () => { + // In Solidity contracts, BurnMintTokenPool is typically enum value 0 + const result = poolTypeToNumber('BurnMintTokenPool'); + expect(result).toBe(0); + }); + + it('should match expected contract enum values for LockReleaseTokenPool', () => { + // In Solidity contracts, LockReleaseTokenPool is typically enum value 1 + const result = poolTypeToNumber('LockReleaseTokenPool'); + expect(result).toBe(1); + }); + + it('should provide valid values for contract calls', () => { + const poolTypes: PoolType[] = ['BurnMintTokenPool', 'LockReleaseTokenPool']; + + poolTypes.forEach((poolType) => { + const numericValue = poolTypeToNumber(poolType); + expect(numericValue).toBeGreaterThanOrEqual(0); + expect(numericValue).toBeLessThanOrEqual(1); + expect(Number.isInteger(numericValue)).toBe(true); + }); + }); + + it('should handle all valid PoolType values', () => { + const validPoolTypes: PoolType[] = ['BurnMintTokenPool', 'LockReleaseTokenPool']; + + validPoolTypes.forEach((poolType) => { + expect(() => poolTypeToNumber(poolType)).not.toThrow(); + }); + }); +}); + +describe('Type safety', () => { + it('should accept PoolType from type system', () => { + const createPoolConfig = (type: PoolType) => { + return { + type, + numericType: poolTypeToNumber(type), + }; + }; + + const config1 = createPoolConfig('BurnMintTokenPool'); + const config2 = createPoolConfig('LockReleaseTokenPool'); + + expect(config1.numericType).toBe(0); + expect(config2.numericType).toBe(1); + }); + + it('should work with const assertions', () => { + const poolType = 'BurnMintTokenPool' as const; + const result = poolTypeToNumber(poolType); + expect(result).toBe(0); + }); + + it('should work in conditional expressions', () => { + const poolType: PoolType = 'BurnMintTokenPool'; + const numericValue = poolTypeToNumber(poolType); + + const isBurnMint = numericValue === 0; + const isLockRelease = numericValue === 1; + + expect(isBurnMint).toBe(true); + expect(isLockRelease).toBe(false); + }); +}); + +describe('Edge cases', () => { + it('should throw error for float 0.5', () => { + expect(() => numberToPoolType(0.5)).toThrow('Invalid pool type value: 0.5. Expected 0 or 1.'); + }); + + it('should throw error for float 0.9', () => { + expect(() => numberToPoolType(0.9)).toThrow('Invalid pool type value: 0.9. Expected 0 or 1.'); + }); + + it('should throw error for float 1.1', () => { + expect(() => numberToPoolType(1.1)).toThrow('Invalid pool type value: 1.1. Expected 0 or 1.'); + }); + + it('should handle negative zero', () => { + const result = numberToPoolType(-0); + expect(result).toBe('BurnMintTokenPool'); + }); + + it('should work with number from parsing', () => { + const numString = '0'; + const num = parseInt(numString, 10); + const result = numberToPoolType(num); + expect(result).toBe('BurnMintTokenPool'); + }); + + it('should work with number from calculation', () => { + const num = 2 - 2; // Results in 0 + const result = numberToPoolType(num); + expect(result).toBe('BurnMintTokenPool'); + }); + + it('should maintain type consistency in arrays', () => { + const poolTypes: PoolType[] = ['BurnMintTokenPool', 'LockReleaseTokenPool']; + const numericTypes = poolTypes.map(poolTypeToNumber); + + expect(numericTypes).toEqual([0, 1]); + + const converted = numericTypes.map(numberToPoolType); + expect(converted).toEqual(poolTypes); + }); + + it('should handle Object.is strict equality', () => { + const num1 = poolTypeToNumber('BurnMintTokenPool'); + const num2 = poolTypeToNumber('BurnMintTokenPool'); + + expect(Object.is(num1, num2)).toBe(true); + }); +}); + +describe('Error messages', () => { + it('should provide clear error message with actual value', () => { + try { + numberToPoolType(5); + fail('Should have thrown error'); + } catch (error) { + if (error instanceof Error) { + expect(error.message).toContain('5'); + expect(error.message).toContain('Expected 0 or 1'); + } else { + fail('Error should be instance of Error'); + } + } + }); + + it('should provide clear error message for large numbers', () => { + try { + numberToPoolType(1000000); + fail('Should have thrown error'); + } catch (error) { + if (error instanceof Error) { + expect(error.message).toContain('1000000'); + } else { + fail('Error should be instance of Error'); + } + } + }); + + it('should include "Invalid pool type value" in error message', () => { + try { + numberToPoolType(42); + fail('Should have thrown error'); + } catch (error) { + if (error instanceof Error) { + expect(error.message).toContain('Invalid pool type value'); + } else { + fail('Error should be instance of Error'); + } + } + }); +}); diff --git a/src/test/utils/safeJsonBuilder.test.ts b/src/test/utils/safeJsonBuilder.test.ts new file mode 100644 index 0000000..bfa4437 --- /dev/null +++ b/src/test/utils/safeJsonBuilder.test.ts @@ -0,0 +1,696 @@ +/** + * Tests for safeJsonBuilder utility + * Covers Safe Transaction Builder JSON creation + */ + +import { ethers } from 'ethers'; +import { + buildSafeTransactionJson, + buildMultiSafeTransactionJson, + SafeJsonBuilderOptions, + MultiSafeJsonBuilderOptions, +} from '../../utils/safeJsonBuilder'; +import { SafeOperationType, SAFE_TX_BUILDER_VERSION } from '../../types/safe'; +import { DEFAULTS } from '../../config'; +import { TokenPool__factory, FactoryBurnMintERC20__factory } from '../../typechain'; + +describe('buildSafeTransactionJson', () => { + const mockMetadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + }; + + const mockTransaction = { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }; + + let tokenPoolInterface: ethers.Interface; + + beforeEach(() => { + tokenPoolInterface = TokenPool__factory.createInterface(); + }); + + it('should build valid Safe transaction JSON', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test Transaction', + description: 'Test Description', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result).toMatchObject({ + version: DEFAULTS.SAFE_TX_VERSION, + chainId: mockMetadata.chainId, + meta: { + name: 'Test Transaction', + description: 'Test Description', + txBuilderVersion: SAFE_TX_BUILDER_VERSION, + createdFromSafeAddress: mockMetadata.safeAddress, + createdFromOwnerAddress: mockMetadata.ownerAddress, + }, + }); + }); + + it('should include transaction data correctly', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions).toHaveLength(1); + expect(result.transactions[0]).toMatchObject({ + to: mockTransaction.to, + value: mockTransaction.value, + data: mockTransaction.data, + operation: mockTransaction.operation, + }); + }); + + it('should include contract method information', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions[0]?.contractMethod).toMatchObject({ + name: 'applyChainUpdates', + payable: false, + }); + expect(result.transactions[0]?.contractMethod?.inputs).toBeDefined(); + expect(Array.isArray(result.transactions[0]?.contractMethod?.inputs)).toBe(true); + }); + + it('should map function inputs correctly', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + const inputs = result.transactions[0]?.contractMethod?.inputs; + + expect(inputs).toBeDefined(); + expect(inputs?.length).toBeGreaterThan(0); + inputs?.forEach((input) => { + expect(input).toHaveProperty('name'); + expect(input).toHaveProperty('type'); + expect(input).toHaveProperty('internalType'); + }); + }); + + it('should set contractInputsValues to null', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions[0]?.contractInputsValues).toBeNull(); + }); + + it('should include timestamp in createdAt', () => { + const beforeTime = Date.now(); + + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + const afterTime = Date.now(); + + expect(result.createdAt).toBeGreaterThanOrEqual(beforeTime); + expect(result.createdAt).toBeLessThanOrEqual(afterTime); + }); + + it('should throw error for non-existent function', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'nonExistentFunction', + }; + + expect(() => buildSafeTransactionJson(options)).toThrow( + 'Function nonExistentFunction not found in contract interface', + ); + }); + + it('should handle different operation types', () => { + const delegateCallTx = { + ...mockTransaction, + operation: SafeOperationType.DelegateCall, + }; + + const options: SafeJsonBuilderOptions = { + transaction: delegateCallTx, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions[0]?.operation).toBe(SafeOperationType.DelegateCall); + }); + + it('should handle different chain IDs', () => { + const differentMetadata = { + ...mockMetadata, + chainId: '84532', // Base Sepolia + }; + + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: differentMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.chainId).toBe('84532'); + }); + + it('should handle non-zero value transactions', () => { + const valueTx = { + ...mockTransaction, + value: '1000000000000000000', // 1 ETH + }; + + const options: SafeJsonBuilderOptions = { + transaction: valueTx, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions[0]?.value).toBe('1000000000000000000'); + }); + + it('should handle different contract interfaces', () => { + const burnMintInterface = FactoryBurnMintERC20__factory.createInterface(); + + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionName: 'mint', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions[0]?.contractMethod?.name).toBe('mint'); + }); + + it('should handle payable functions', () => { + // Note: Most contract functions are non-payable, but testing the mapping + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions[0]?.contractMethod).toHaveProperty('payable'); + }); +}); + +describe('buildMultiSafeTransactionJson', () => { + const mockMetadata = { + chainId: '11155111', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + }; + + let burnMintInterface: ethers.Interface; + + beforeEach(() => { + burnMintInterface = FactoryBurnMintERC20__factory.createInterface(); + }); + + it('should build valid multi-transaction Safe JSON', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x5678', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Multi Test', + description: 'Multi Description', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole', 'grantBurnRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + + expect(result).toMatchObject({ + version: DEFAULTS.SAFE_TX_VERSION, + chainId: mockMetadata.chainId, + meta: { + name: 'Multi Test', + description: 'Multi Description', + txBuilderVersion: SAFE_TX_BUILDER_VERSION, + }, + }); + }); + + it('should include all transactions', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x5678', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole', 'grantBurnRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + + expect(result.transactions).toHaveLength(2); + expect(result.transactions[0]).toMatchObject(mockTransactions[0]!); + expect(result.transactions[1]).toMatchObject(mockTransactions[1]!); + }); + + it('should map function names correctly', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x5678', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole', 'grantBurnRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + + expect(result.transactions[0]?.contractMethod?.name).toBe('grantMintRole'); + expect(result.transactions[1]?.contractMethod?.name).toBe('grantBurnRole'); + }); + + it('should throw error for mismatched array lengths', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x5678', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole'], // Only 1 function name for 2 transactions + }; + + expect(() => buildMultiSafeTransactionJson(options)).toThrow( + 'Mismatch between transactions (2) and functionNames (1)', + ); + }); + + it('should throw error for non-existent function', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['nonExistentFunction'], + }; + + expect(() => buildMultiSafeTransactionJson(options)).toThrow( + 'Function nonExistentFunction not found in contract interface', + ); + }); + + it('should handle single transaction array', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + + expect(result.transactions).toHaveLength(1); + }); + + it('should handle many transactions', () => { + const mockTransactions = Array.from({ length: 5 }, (_, i) => ({ + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: `0x${i}`, + operation: SafeOperationType.Call, + })); + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole', 'grantBurnRole', 'grantMintRole', 'grantBurnRole', 'mint'], + }; + + const result = buildMultiSafeTransactionJson(options); + + expect(result.transactions).toHaveLength(5); + }); + + it('should include contract method inputs for all transactions', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x5678', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole', 'grantBurnRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + + result.transactions.forEach((tx) => { + expect(tx.contractMethod).toBeDefined(); + expect(tx.contractMethod?.inputs).toBeDefined(); + expect(Array.isArray(tx.contractMethod?.inputs)).toBe(true); + }); + }); + + it('should set contractInputsValues to null for all transactions', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x5678', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole', 'grantBurnRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + + result.transactions.forEach((tx) => { + expect(tx.contractInputsValues).toBeNull(); + }); + }); + + it('should handle mixed operation types', () => { + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x5678', + operation: SafeOperationType.DelegateCall, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole', 'grantBurnRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + + expect(result.transactions[0]?.operation).toBe(SafeOperationType.Call); + expect(result.transactions[1]?.operation).toBe(SafeOperationType.DelegateCall); + }); + + it('should include timestamp in createdAt', () => { + const beforeTime = Date.now(); + + const mockTransactions = [ + { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x1234', + operation: SafeOperationType.Call, + }, + ]; + + const options: MultiSafeJsonBuilderOptions = { + transactions: mockTransactions, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: burnMintInterface, + functionNames: ['grantMintRole'], + }; + + const result = buildMultiSafeTransactionJson(options); + const afterTime = Date.now(); + + expect(result.createdAt).toBeGreaterThanOrEqual(beforeTime); + expect(result.createdAt).toBeLessThanOrEqual(afterTime); + }); +}); + +describe('Edge Cases', () => { + const mockMetadata = { + chainId: '1', + safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + ownerAddress: '0x0000000000000000000000000000000000000000', + }; + + const mockTransaction = { + to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + value: '0', + data: '0x', + operation: SafeOperationType.Call, + }; + + let tokenPoolInterface: ethers.Interface; + + beforeEach(() => { + tokenPoolInterface = TokenPool__factory.createInterface(); + }); + + it('should handle empty transaction data', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.transactions[0]?.data).toBe('0x'); + }); + + it('should handle empty name and description', () => { + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: '', + description: '', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.meta.name).toBe(''); + expect(result.meta.description).toBe(''); + }); + + it('should handle long name and description', () => { + const longString = 'x'.repeat(1000); + + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: mockMetadata, + name: longString, + description: longString, + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.meta.name).toBe(longString); + expect(result.meta.description).toBe(longString); + }); + + it('should handle zero address as Safe address', () => { + const zeroMetadata = { + ...mockMetadata, + safeAddress: '0x0000000000000000000000000000000000000000', + }; + + const options: SafeJsonBuilderOptions = { + transaction: mockTransaction, + metadata: zeroMetadata, + name: 'Test', + description: 'Test', + contractInterface: tokenPoolInterface, + functionName: 'applyChainUpdates', + }; + + const result = buildSafeTransactionJson(options); + + expect(result.meta.createdFromSafeAddress).toBe('0x0000000000000000000000000000000000000000'); + }); +}); diff --git a/src/types/allowList.ts b/src/types/allowList.ts new file mode 100644 index 0000000..922af77 --- /dev/null +++ b/src/types/allowList.ts @@ -0,0 +1,199 @@ +/** + * @fileoverview Type definitions and validation schemas for TokenPool allow list management. + * + * This module defines the input parameters and validation rules for managing the allow list + * of addresses authorized to interact with a TokenPool. The allow list controls which addresses + * can send or receive tokens through the pool's cross-chain transfer functionality. + * + * Allow list updates support atomic add/remove operations in a single transaction. + * + * @module types/allowList + */ + +import { z } from 'zod'; +import { ethers } from 'ethers'; +import { SafeMetadata } from './safe'; + +/** + * Zod schema for allow list update operations. + * + * Defines the parameters for adding and removing addresses from a TokenPool's allow list. + * Supports batching multiple additions and removals in a single atomic transaction. + * + * @remarks + * Validation Rules: + * - **removes**: Optional array of addresses to remove from allow list (defaults to empty array) + * - **adds**: Optional array of addresses to add to allow list (defaults to empty array) + * - All addresses are validated as proper Ethereum addresses + * + * Allow List Behavior: + * - Empty allow list = all addresses permitted (no restrictions) + * - Non-empty allow list = only listed addresses permitted + * - Removals are processed before additions + * - All operations are atomic (succeed together or fail together) + * + * Use Cases: + * - Restrict token transfers to specific addresses or contracts + * - Whitelist authorized users for compliant tokens + * - Remove compromised or unauthorized addresses + * - Batch multiple allow list changes + * + * @example + * ```typescript + * // Add addresses to allow list + * const addAddresses: AllowListUpdatesInput = { + * removes: [], + * adds: [ + * "0x1234567890123456789012345678901234567890", + * "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + * ] + * }; + * + * allowListUpdatesSchema.parse(addAddresses); // Valid + * ``` + * + * @example + * ```typescript + * // Remove addresses from allow list + * const removeAddresses: AllowListUpdatesInput = { + * removes: ["0x1234567890123456789012345678901234567890"], + * adds: [] + * }; + * + * allowListUpdatesSchema.parse(removeAddresses); // Valid + * ``` + * + * @example + * ```typescript + * // Replace addresses (remove old, add new) in single transaction + * const replaceAddresses: AllowListUpdatesInput = { + * removes: [ + * "0xOldAddress1", + * "0xOldAddress2" + * ], + * adds: [ + * "0xNewAddress1", + * "0xNewAddress2", + * "0xNewAddress3" + * ] + * }; + * + * allowListUpdatesSchema.parse(replaceAddresses); // Valid + * ``` + * + * @example + * ```typescript + * // Clear allow list (remove restrictions) + * const clearAllowList: AllowListUpdatesInput = { + * removes: [ + * "0xAddress1", + * "0xAddress2", + * "0xAddress3" + * ], + * adds: [] + * }; + * // After this, allow list is empty = all addresses permitted + * ``` + * + * @example + * ```typescript + * // Minimal input (no changes) + * const noChanges: AllowListUpdatesInput = { + * // removes defaults to [] + * // adds defaults to [] + * }; + * + * const parsed = allowListUpdatesSchema.parse(noChanges); + * console.log(parsed.removes); // [] + * console.log(parsed.adds); // [] + * ``` + * + * @example + * ```typescript + * // Validation error for invalid address + * try { + * const invalid = { + * adds: ["0xInvalidAddress"] + * }; + * allowListUpdatesSchema.parse(invalid); + * } catch (error) { + * console.error(error.message); // "Invalid Ethereum address format in adds array" + * } + * ``` + * + * @see {@link AllowListUpdatesInput} for inferred TypeScript type + * @see {@link createAllowListUpdatesGenerator} for usage in generator + * + * @public + */ +export const allowListUpdatesSchema = z.object({ + /** Array of addresses to remove from allow list - validated Ethereum addresses */ + removes: z + .array( + z.string().refine((address: string): boolean => ethers.isAddress(address), { + message: 'Invalid Ethereum address format in removes array', + }), + ) + .optional() + .default([]), + + /** Array of addresses to add to allow list - validated Ethereum addresses */ + adds: z + .array( + z.string().refine((address: string): boolean => ethers.isAddress(address), { + message: 'Invalid Ethereum address format in adds array', + }), + ) + .optional() + .default([]), +}); + +/** + * TypeScript type for validated allow list update parameters. + * + * @example + * ```typescript + * function updateAllowList(params: AllowListUpdatesInput) { + * console.log(`Removing ${params.removes.length} addresses`); + * console.log(`Adding ${params.adds.length} addresses`); + * } + * + * const validated = allowListUpdatesSchema.parse(userInput); + * updateAllowList(validated); + * ``` + * + * @public + */ +export type AllowListUpdatesInput = z.infer; + +/** + * Safe Transaction Builder metadata for allow list operations. + * + * Extends SafeMetadata with TokenPool address information required for formatting + * allow list update transactions as Safe Transaction Builder JSON. + * + * @remarks + * This metadata is used by the allow list formatter to generate Safe Transaction Builder + * JSON files for managing pool allow lists via multisig. + * + * @example + * ```typescript + * const metadata: SafeAllowListMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * ownerAddress: "0x0000000000000000000000000000000000000000", + * tokenPoolAddress: "0x1234567890123456789012345678901234567890" // TokenPool + * }; + * + * const safeJson = await formatter.formatAsSafeJson(allowListTx, metadata); + * ``` + * + * @see {@link SafeMetadata} for base metadata fields + * @see {@link AllowListFormatter} for usage in formatting + * + * @public + */ +export interface SafeAllowListMetadata extends SafeMetadata { + /** Address of the TokenPool contract to update */ + tokenPoolAddress: string; +} diff --git a/src/types/chainUpdate.ts b/src/types/chainUpdate.ts index 4384d65..838d3e0 100644 --- a/src/types/chainUpdate.ts +++ b/src/types/chainUpdate.ts @@ -1,43 +1,441 @@ +/** + * @fileoverview Type definitions and validation schemas for cross-chain pool configuration updates. + * + * This module defines the input parameters and validation rules for updating TokenPool chain + * configurations via the applyChainUpdates function. Supports multi-chain architectures + * including EVM (Ethereum), SVM (Solana), and MVM (Move-based chains, not yet implemented). + * + * Chain updates allow adding new remote chains or removing existing chains from a token pool's + * cross-chain transfer configuration, with separate rate limiting for inbound and outbound transfers. + * + * @module types/chainUpdate + */ + import { z } from 'zod'; +/** + * Blockchain architecture type enumeration. + * + * Defines the supported blockchain architectures for cross-chain token transfers. + * Each chain type has different address formats and encoding requirements. + * + * @remarks + * Chain Types: + * - **EVM**: Ethereum Virtual Machine chains (Ethereum, Base, Arbitrum, etc.) + * - Address format: 20-byte hex (e.g., `0x779877A7B0D9E8603169DdbD7836e478b4624789`) + * - Encoded as: `address` type in ABI encoding + * + * - **SVM**: Solana Virtual Machine (Solana, Solana-based chains) + * - Address format: 32-byte base58-encoded public key (e.g., `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`) + * - Encoded as: `bytes32` type in ABI encoding + * + * - **MVM**: Move Virtual Machine (Aptos, Sui, etc.) + * - Status: Not yet implemented + * - Planned for future support + * + * The chain type is used by address validators to ensure correct format and by + * encoders to properly ABI-encode addresses for contract calls. + * + * @example + * ```typescript + * // EVM chain configuration + * const evmChain: ChainType = ChainType.EVM; + * + * // SVM chain configuration + * const svmChain: ChainType = ChainType.SVM; + * ``` + * + * @see {@link chainUpdateSchema} for usage in chain update validation + * + * @public + */ export enum ChainType { + /** Ethereum Virtual Machine chains (Ethereum, Base, Arbitrum, etc.) */ EVM = 'evm', + /** Solana Virtual Machine (Solana, Solana-based chains) */ SVM = 'svm', + /** Move Virtual Machine (Aptos, Sui) - not yet implemented */ MVM = 'mvm', } -// Schema for rate limiter configuration +/** + * Zod schema for rate limiter configuration in chain updates. + * + * Defines token bucket rate limiter parameters that control the rate and capacity + * of token transfers. This schema is identical to the one in poolDeployment.ts but + * defined separately to avoid circular dependencies. + * + * @remarks + * Token Bucket Algorithm: + * - **isEnabled**: Enable/disable the rate limiter for this direction + * - **capacity**: Maximum tokens in bucket (burst limit) as string wei + * - **rate**: Token refill rate per second as string wei/sec + * + * Amount Format: + * - Values are strings to avoid JavaScript number precision issues + * - Represent token amounts in smallest unit (wei) + * - Example: "1000000000000000000" = 1 token with 18 decimals + * + * Bidirectional Rate Limiting: + * - Each chain connection has TWO rate limiters: inbound and outbound + * - Outbound: Limits tokens sent FROM this pool TO remote chain + * - Inbound: Limits tokens received FROM remote chain TO this pool + * + * @example + * ```typescript + * const config: RateLimiterConfig = { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens max burst + * rate: "100000000000000000" // 0.1 tokens/sec refill + * }; + * + * rateLimiterConfigSchema.parse(config); // Valid + * ``` + * + * @example + * ```typescript + * // Disabled rate limiter + * const disabled: RateLimiterConfig = { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * }; + * ``` + * + * @see {@link chainUpdateSchema} for usage in chain updates + * + * @public + */ export const rateLimiterConfigSchema = z.object({ + /** Enable/disable the rate limiter */ isEnabled: z.boolean(), - capacity: z.string(), // BigNumber as string - rate: z.string(), // BigNumber as string + + /** Maximum token amount in bucket (wei as string) */ + capacity: z.string(), + + /** Token refill rate per second (wei/sec as string) */ + rate: z.string(), }); -// Schema for a single chain update +/** + * Zod schema for a single chain update operation. + * + * Defines the complete configuration for adding or updating a remote chain connection + * in a token pool. Each chain update specifies the remote pool addresses, token address, + * and bidirectional rate limiting configuration. + * + * @remarks + * Validation Rules: + * - **remoteChainSelector**: Chain selector identifying the remote blockchain (uint64 as string) + * - **remotePoolAddresses**: Array of pool addresses on remote chain (may be empty initially) + * - **remoteTokenAddress**: Token contract address on remote chain + * - **outboundRateLimiterConfig**: Rate limits for tokens sent TO remote chain + * - **inboundRateLimiterConfig**: Rate limits for tokens received FROM remote chain + * - **remoteChainType**: Chain architecture (EVM, SVM, MVM) for address validation + * + * Chain Architecture Handling: + * - EVM chains: 20-byte addresses (e.g., `0x1234...`) + * - SVM chains: 32-byte base58 public keys (e.g., `TokenkegQ...`) + * - MVM chains: Not yet implemented + * + * Pool Addresses Array: + * - Can contain multiple pools on the same chain + * - Empty array is valid (no pools configured yet) + * - Used for allow-listing pools authorized to interact with this pool + * + * @example + * ```typescript + * // EVM chain update (Ethereum Sepolia) + * const evmUpdate: ChainUpdateInput = { + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * remotePoolAddresses: [ + * "0x1234567890123456789012345678901234567890" + * ], + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * outboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * }, + * inboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "2000000000000000000000", + * rate: "200000000000000000" + * }, + * remoteChainType: ChainType.EVM + * }; + * + * chainUpdateSchema.parse(evmUpdate); // Valid + * ``` + * + * @example + * ```typescript + * // SVM chain update (Solana) + * const svmUpdate: ChainUpdateInput = { + * remoteChainSelector: "3734403246176062136", // Solana Devnet + * remotePoolAddresses: [], // No pools yet + * remoteTokenAddress: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + * outboundRateLimiterConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * }, + * inboundRateLimiterConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * }, + * remoteChainType: ChainType.SVM + * }; + * + * chainUpdateSchema.parse(svmUpdate); // Valid + * ``` + * + * @example + * ```typescript + * // Asymmetric rate limiting (different inbound/outbound limits) + * const asymmetric: ChainUpdateInput = { + * remoteChainSelector: "10344971235874465080", // Base Sepolia + * remotePoolAddresses: ["0xPool1", "0xPool2"], + * remoteTokenAddress: "0xToken", + * outboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "5000000000000000000000", // 5000 tokens outbound + * rate: "500000000000000000" + * }, + * inboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "10000000000000000000000", // 10000 tokens inbound + * rate: "1000000000000000000" + * }, + * remoteChainType: ChainType.EVM + * }; + * ``` + * + * @see {@link chainUpdatesInputSchema} for full update operations + * + * @public + */ export const chainUpdateSchema = z.object({ - remoteChainSelector: z.string(), // BigNumber as string - remotePoolAddresses: z.array(z.string()), // Array of addresses - remoteTokenAddress: z.string(), // Address + /** Chain selector identifying the remote blockchain (uint64 as string) */ + remoteChainSelector: z.string(), + + /** Array of pool addresses on remote chain (may be empty) */ + remotePoolAddresses: z.array(z.string()), + + /** Token contract address on remote chain */ + remoteTokenAddress: z.string(), + + /** Rate limiter configuration for outbound transfers (TO remote chain) */ outboundRateLimiterConfig: rateLimiterConfigSchema, + + /** Rate limiter configuration for inbound transfers (FROM remote chain) */ inboundRateLimiterConfig: rateLimiterConfigSchema, - // @TODO dev = mvm not implemented - remoteChainType: z.nativeEnum(ChainType), // needed to validate addresses. + + /** Chain architecture type (EVM, SVM, MVM) for address validation and encoding */ + remoteChainType: z.nativeEnum(ChainType), }); -// Schema for the entire chain updates input +/** + * Zod schema for complete chain updates input. + * + * Defines the tuple structure for the TokenPool's applyChainUpdates function call, + * which allows both removing existing chains and adding/updating new chains in a + * single atomic transaction. + * + * @remarks + * Tuple Structure: + * - **Index 0**: Array of chain selectors to remove (strings representing uint64) + * - **Index 1**: Array of chain update configurations to add/update + * + * Operation Semantics: + * 1. Removals are processed first (index 0) + * 2. Additions/updates are processed second (index 1) + * 3. All operations are atomic (succeed together or fail together) + * + * Use Cases: + * - **Add new chain**: Empty removals array, one or more updates + * - **Remove chain**: Chain selector in removals, empty updates + * - **Update existing**: Chain selector in removals AND updates (remove old, add new) + * - **Batch operations**: Multiple chains added/removed in one transaction + * + * @example + * ```typescript + * // Add two new chains (no removals) + * const addChains: ChainUpdatesInput = [ + * [], // No chains to remove + * [ + * { + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * remotePoolAddresses: ["0xPool1"], + * remoteTokenAddress: "0xToken1", + * outboundRateLimiterConfig: { isEnabled: true, capacity: "1000", rate: "100" }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: "1000", rate: "100" }, + * remoteChainType: ChainType.EVM + * }, + * { + * remoteChainSelector: "10344971235874465080", // Base Sepolia + * remotePoolAddresses: ["0xPool2"], + * remoteTokenAddress: "0xToken2", + * outboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * remoteChainType: ChainType.EVM + * } + * ] + * ]; + * + * chainUpdatesInputSchema.parse(addChains); // Valid + * ``` + * + * @example + * ```typescript + * // Remove one chain + * const removeChain: ChainUpdatesInput = [ + * ["16015286601757825753"], // Remove Ethereum Sepolia + * [] // No chains to add + * ]; + * + * chainUpdatesInputSchema.parse(removeChain); // Valid + * ``` + * + * @example + * ```typescript + * // Update existing chain (remove old config, add new config) + * const updateChain: ChainUpdatesInput = [ + * ["16015286601757825753"], // Remove old Ethereum Sepolia config + * [ + * { + * remoteChainSelector: "16015286601757825753", // Add new config + * remotePoolAddresses: ["0xNewPool"], // Updated pool + * remoteTokenAddress: "0xToken", + * outboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "5000000000000000000000", // Increased capacity + * rate: "500000000000000000" + * }, + * inboundRateLimiterConfig: { + * isEnabled: true, + * capacity: "5000000000000000000000", + * rate: "500000000000000000" + * }, + * remoteChainType: ChainType.EVM + * } + * ] + * ]; + * ``` + * + * @example + * ```typescript + * // Mixed EVM and SVM chains + * const mixedChains: ChainUpdatesInput = [ + * [], + * [ + * { + * remoteChainSelector: "16015286601757825753", // EVM + * remotePoolAddresses: ["0xEVMPool"], + * remoteTokenAddress: "0xEVMToken", + * outboundRateLimiterConfig: { isEnabled: true, capacity: "1000", rate: "100" }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: "1000", rate: "100" }, + * remoteChainType: ChainType.EVM + * }, + * { + * remoteChainSelector: "3734403246176062136", // SVM + * remotePoolAddresses: [], + * remoteTokenAddress: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + * outboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: "0", rate: "0" }, + * remoteChainType: ChainType.SVM + * } + * ] + * ]; + * ``` + * + * @see {@link ChainUpdateInput} for inferred TypeScript type + * @see {@link createChainUpdateGenerator} for usage in generator + * + * @public + */ export const chainUpdatesInputSchema = z.tuple([ z.array(z.string()), // Chain selectors to remove z.array(chainUpdateSchema), // Chain updates to add ]); +/** + * TypeScript type for rate limiter configuration. + * @public + */ export type RateLimiterConfig = z.infer; + +/** + * TypeScript type for a single chain update operation. + * + * @example + * ```typescript + * function processChainUpdate(update: ChainUpdateInput) { + * console.log(`Processing chain ${update.remoteChainSelector}`); + * console.log(`Chain type: ${update.remoteChainType}`); + * } + * ``` + * + * @public + */ export type ChainUpdateInput = z.infer; + +/** + * TypeScript type for complete chain updates input. + * + * Represents the validated tuple structure for applyChainUpdates function calls. + * + * @example + * ```typescript + * function applyChainUpdates(updates: ChainUpdatesInput) { + * const [removals, additions] = updates; + * console.log(`Removing ${removals.length} chains`); + * console.log(`Adding ${additions.length} chains`); + * } + * + * const validated = chainUpdatesInputSchema.parse(userInput); + * applyChainUpdates(validated); + * ``` + * + * @public + */ export type ChainUpdatesInput = z.infer; -// Safe Transaction Builder types +/** + * Safe Transaction Builder metadata for chain updates. + * + * Defines the metadata required to format chain update transactions for the + * Safe Transaction Builder JSON format. + * + * @remarks + * This interface is used by the chain update formatter to add transaction + * metadata when outputting Safe Transaction Builder JSON format. + * + * @example + * ```typescript + * const metadata: SafeChainUpdateMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * ownerAddress: "0x0000000000000000000000000000000000000000", + * tokenPoolAddress: "0x1234567890123456789012345678901234567890" + * }; + * ``` + * + * @see {@link ChainUpdateFormatter} for usage in formatting + * + * @public + */ export interface SafeChainUpdateMetadata { + /** Chain ID where the transaction will be executed */ chainId: string; + + /** Address of the Safe multisig contract */ safeAddress: string; + + /** Address of the Safe owner executing the transaction */ ownerAddress: string; + + /** Address of the TokenPool contract to update */ tokenPoolAddress: string; } diff --git a/src/types/commandTypes.ts b/src/types/commandTypes.ts new file mode 100644 index 0000000..6bf4263 --- /dev/null +++ b/src/types/commandTypes.ts @@ -0,0 +1,457 @@ +/** + * @fileoverview Discriminated union types for CLI command configurations. + * + * This module defines TypeScript types for all CLI commands using discriminated unions + * to provide better type safety and enable exhaustive checking. The command types support + * two output formats: raw calldata and Safe Transaction Builder JSON. + * + * Discriminated unions allow the TypeScript compiler to narrow types based on the command + * type, ensuring correct parameters are provided for each command variant. + * + * @module types/commandTypes + */ + +import { OUTPUT_FORMAT } from '../config'; + +/** + * Base command configuration shared across all commands. + * + * Provides the optional output file path that all commands support. + * + * @remarks + * If output is not specified, results are written to stdout. + * + * @internal + */ +interface BaseCommandConfig { + /** Optional output file path (if not provided, writes to stdout) */ + output?: string; +} + +/** + * Configuration for commands that output Safe Transaction Builder JSON format. + * + * When using Safe JSON format, additional metadata is required to generate + * transactions compatible with the Safe Transaction Builder UI. + * + * @remarks + * Safe JSON format requires: + * - Chain ID where transaction will be executed + * - Safe multisig contract address + * - Owner address executing the transaction + * + * @example + * ```typescript + * const safeConfig: SafeJsonConfig = { + * format: OUTPUT_FORMAT.SAFE_JSON, + * chainId: "84532", // Base Sepolia + * safe: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * owner: "0x0000000000000000000000000000000000000000" + * }; + * ``` + * + * @see {@link OUTPUT_FORMAT} for format constant values + * + * @public + */ +interface SafeJsonConfig { + /** Output format must be 'safe-json' */ + format: typeof OUTPUT_FORMAT.SAFE_JSON; + + /** Chain ID where transaction will be executed */ + chainId: string; + + /** Safe multisig contract address */ + safe: string; + + /** Owner address executing the transaction */ + owner: string; +} + +/** + * Configuration for commands that output raw calldata format. + * + * Calldata format is the default and requires no additional metadata. + * + * @remarks + * Calldata format outputs hex-encoded function call data that can be: + * - Used directly with web3 libraries + * - Pasted into block explorers + * - Used with cast send (Foundry) + * - Manually constructed transactions + * + * @example + * ```typescript + * const calldataConfig: CalldataConfig = { + * format: OUTPUT_FORMAT.CALLDATA // Optional, this is the default + * }; + * ``` + * + * @see {@link OUTPUT_FORMAT} for format constant values + * + * @public + */ +interface CalldataConfig { + /** Optional output format (defaults to 'calldata') */ + format?: typeof OUTPUT_FORMAT.CALLDATA; +} + +/** + * Discriminated union for output format configurations. + * + * Commands can output either Safe JSON or raw calldata format. + * The format determines what additional parameters are required. + * + * @example + * ```typescript + * function processOutput(config: OutputConfig) { + * if (isSafeJsonConfig(config)) { + * // TypeScript knows config has chainId, safe, owner + * console.log(`Safe: ${config.safe}, Chain: ${config.chainId}`); + * } else { + * // TypeScript knows config is CalldataConfig + * console.log('Outputting raw calldata'); + * } + * } + * ``` + * + * @public + */ +export type OutputConfig = SafeJsonConfig | CalldataConfig; + +/** + * Type guard to check if configuration uses Safe JSON format. + * + * Enables TypeScript type narrowing to access Safe-specific properties. + * + * @param config - Output configuration to check + * @returns True if config uses Safe JSON format + * + * @example + * ```typescript + * const config: OutputConfig = getConfig(); + * + * if (isSafeJsonConfig(config)) { + * // TypeScript knows config is SafeJsonConfig + * const metadata = { + * chainId: config.chainId, + * safeAddress: config.safe, + * ownerAddress: config.owner + * }; + * } + * ``` + * + * @public + */ +export function isSafeJsonConfig(config: OutputConfig): config is SafeJsonConfig { + return 'format' in config && config.format === OUTPUT_FORMAT.SAFE_JSON; +} + +/** + * Chain update command configuration. + * + * Used for `generate-chain-update` CLI command which updates TokenPool + * cross-chain configurations (adding/removing chains). + * + * @remarks + * Parameters: + * - **input**: Path to JSON file with chain update configuration + * - **tokenPool**: Optional TokenPool address (required for Safe JSON format) + * + * @example + * ```typescript + * const command: ChainUpdateCommand = { + * input: "examples/chain-update.json", + * tokenPool: "0x1234567890123456789012345678901234567890", + * format: OUTPUT_FORMAT.SAFE_JSON, + * chainId: "84532", + * safe: "0xSafe", + * owner: "0xOwner", + * output: "output/chain-update.json" + * }; + * ``` + * + * @public + */ +export type ChainUpdateCommand = BaseCommandConfig & + OutputConfig & { + /** Path to JSON file containing chain update parameters */ + input: string; + + /** Optional TokenPool address (required for Safe JSON format) */ + tokenPool?: string; + }; + +/** + * Deployment command configuration for token and pool deployment. + * + * Used for `generate-token-deployment` and `generate-pool-deployment` CLI commands. + * + * @remarks + * Parameters: + * - **input**: Path to JSON file with deployment parameters + * - **deployer**: TokenPoolFactory contract address + * - **salt**: 32-byte salt value for CREATE2 deployment + * + * @example + * ```typescript + * const command: DeploymentCommand = { + * input: "examples/token-deployment.json", + * deployer: "0x17d8a409fe2cef2d3808bcb61f14abeffc28876e", + * salt: "0x0000000000000000000000000000000000000000000000000000000123456789", + * format: OUTPUT_FORMAT.SAFE_JSON, + * chainId: "84532", + * safe: "0xSafe", + * owner: "0xOwner" + * }; + * ``` + * + * @public + */ +export type DeploymentCommand = BaseCommandConfig & + OutputConfig & { + /** Path to JSON file containing deployment parameters */ + input: string; + + /** TokenPoolFactory contract address */ + deployer: string; + + /** 32-byte salt value for CREATE2 deployment (hex string) */ + salt: string; + }; + +/** + * Mint command configuration. + * + * Used for `generate-mint` CLI command which mints tokens to a receiver. + * + * @remarks + * Parameters: + * - **token**: BurnMintERC20 token contract address + * - **receiver**: Address receiving the minted tokens + * - **amount**: Amount to mint in wei (as string) + * + * @example + * ```typescript + * const command: MintCommand = { + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * receiver: "0x1234567890123456789012345678901234567890", + * amount: "1000000000000000000000", // 1000 tokens + * format: OUTPUT_FORMAT.SAFE_JSON, + * chainId: "84532", + * safe: "0xSafe", + * owner: "0xOwner" + * }; + * ``` + * + * @public + */ +export type MintCommand = BaseCommandConfig & + OutputConfig & { + /** Token contract address */ + token: string; + + /** Receiver address for minted tokens */ + receiver: string; + + /** Amount to mint in wei (string to avoid precision issues) */ + amount: string; + }; + +/** + * Pool operation command configuration. + * + * Used for allow list updates and rate limiter configuration commands. + * + * @remarks + * Parameters: + * - **input**: Path to JSON file with operation parameters + * - **pool**: TokenPool contract address + * + * @example + * ```typescript + * const command: PoolOperationCommand = { + * input: "examples/allow-list.json", + * pool: "0x1234567890123456789012345678901234567890", + * format: OUTPUT_FORMAT.CALLDATA, + * output: "output/allow-list.txt" + * }; + * ``` + * + * @public + */ +export type PoolOperationCommand = BaseCommandConfig & + OutputConfig & { + /** Path to JSON file containing operation parameters */ + input: string; + + /** TokenPool contract address */ + pool: string; + }; + +/** + * Grant roles command configuration. + * + * Used for `generate-grant-roles` CLI command which grants or revokes + * mint and burn roles on BurnMintERC20 tokens. + * + * @remarks + * Parameters: + * - **token**: BurnMintERC20 token contract address + * - **pool**: Pool or address receiving role grant/revoke + * - **roleType**: Type of role(s) - 'mint', 'burn', or 'both' (defaults to 'both') + * - **action**: 'grant' or 'revoke' (defaults to 'grant') + * + * @example + * ```typescript + * const command: GrantRolesCommand = { + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * pool: "0x1234567890123456789012345678901234567890", + * roleType: "both", // Optional, defaults to 'both' + * action: "grant", // Optional, defaults to 'grant' + * format: OUTPUT_FORMAT.SAFE_JSON, + * chainId: "84532", + * safe: "0xSafe", + * owner: "0xOwner" + * }; + * ``` + * + * @public + */ +export type GrantRolesCommand = BaseCommandConfig & + OutputConfig & { + /** Token contract address */ + token: string; + + /** Pool or address receiving role grant/revoke */ + pool: string; + + /** Type of role(s) to manage - defaults to 'both' */ + roleType?: 'mint' | 'burn' | 'both'; + + /** Action to perform - defaults to 'grant' */ + action?: 'grant' | 'revoke'; + }; + +/** + * Accept ownership command configuration. + * + * Used for `generate-accept-ownership` CLI command which accepts ownership + * of contracts using the two-step ownership transfer pattern. + * + * @remarks + * Parameters: + * - **address**: Contract address to accept ownership of (token, pool, or any contract with two-step ownership) + * + * Two-Step Ownership Pattern: + * 1. Current owner calls `transferOwnership(newOwner)` - sets `pendingOwner` + * 2. New owner (the Safe) calls `acceptOwnership()` - becomes the actual `owner` + * + * Common Use Cases: + * - After TokenPoolFactory deployment, Safe is set as pendingOwner on both token and pool + * - Safe must accept ownership before it can call owner-only functions like `applyChainUpdates` + * + * @example + * ```typescript + * const command: AcceptOwnershipCommand = { + * address: "0x1234567890123456789012345678901234567890", + * format: OUTPUT_FORMAT.SAFE_JSON, + * chainId: "84532", + * safe: "0xSafe", + * owner: "0xOwner" + * }; + * ``` + * + * @public + */ +export type AcceptOwnershipCommand = BaseCommandConfig & + OutputConfig & { + /** Contract address to accept ownership of (token, pool, etc.) */ + address: string; + }; + +/** + * Discriminated union of all CLI command types. + * + * Each command variant has a unique 'type' discriminator enabling exhaustive + * type checking and pattern matching. + * + * @remarks + * Command Types: + * - **chain-update**: Update TokenPool chain configurations + * - **token-deployment**: Deploy BurnMintERC20 token with pool + * - **pool-deployment**: Deploy pool for existing token + * - **mint**: Mint tokens to receiver + * - **allow-list**: Update TokenPool allow list + * - **rate-limiter**: Configure TokenPool rate limiters + * - **grant-roles**: Grant/revoke mint and burn roles + * - **accept-ownership**: Accept ownership of a contract + * + * @example + * ```typescript + * function handleCommand(command: Command) { + * switch (command.type) { + * case 'chain-update': + * // TypeScript knows command is ChainUpdateCommand + * return handleChainUpdate(command); + * case 'token-deployment': + * // TypeScript knows command is DeploymentCommand + * return handleTokenDeployment(command); + * case 'mint': + * // TypeScript knows command is MintCommand + * return handleMint(command); + * // ... handle other cases + * default: + * // Exhaustiveness check: TypeScript error if case missed + * const _exhaustive: never = command; + * throw new Error(`Unknown command type: ${_exhaustive}`); + * } + * } + * ``` + * + * @public + */ +export type Command = + | ({ type: 'chain-update' } & ChainUpdateCommand) + | ({ type: 'token-deployment' } & DeploymentCommand) + | ({ type: 'pool-deployment' } & DeploymentCommand) + | ({ type: 'mint' } & MintCommand) + | ({ type: 'allow-list' } & PoolOperationCommand) + | ({ type: 'rate-limiter' } & PoolOperationCommand) + | ({ type: 'grant-roles' } & GrantRolesCommand) + | ({ type: 'accept-ownership' } & AcceptOwnershipCommand); + +/** + * Type guard for command discrimination. + * + * Checks if an unknown value is a valid Command with a type discriminator. + * + * @param value - Value to check + * @returns True if value is a Command + * + * @remarks + * This is a lightweight check that only validates the presence of a 'type' + * field. Full validation is done by command-specific validators. + * + * @example + * ```typescript + * const input: unknown = parseCliArgs(); + * + * if (isCommand(input)) { + * // TypeScript knows input is Command + * handleCommand(input); + * } else { + * throw new Error('Invalid command structure'); + * } + * ``` + * + * @public + */ +export function isCommand(value: unknown): value is Command { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof (value as Record).type === 'string' + ); +} diff --git a/src/types/poolDeployment.ts b/src/types/poolDeployment.ts index df489a8..eb49dea 100644 --- a/src/types/poolDeployment.ts +++ b/src/types/poolDeployment.ts @@ -1,53 +1,334 @@ +/** + * @fileoverview Type definitions and validation schemas for pool deployment operations. + * + * This module defines the input parameters and validation rules for deploying TokenPools + * for existing tokens via the TokenPoolFactory. Supports both BurnMintTokenPool and + * LockReleaseTokenPool types with cross-chain configuration and rate limiting. + * + * @module types/poolDeployment + */ + import { z } from 'zod'; import { ethers } from 'ethers'; -// Pool type discriminator (matches contract's PoolType enum) +/** + * Zod schema for pool type selection. + * + * Defines the two supported pool types that determine token transfer behavior: + * - **BurnMintTokenPool**: Burns tokens on source chain, mints on destination + * - **LockReleaseTokenPool**: Locks tokens on one chain, releases on another + * + * @remarks + * Pool type selection depends on token capabilities: + * - Use BurnMintTokenPool if token has mint/burn functions + * - Use LockReleaseTokenPool if token has fixed supply or lacks mint/burn + * + * The enum values match the contract's PoolType enum (0 = BurnMint, 1 = LockRelease). + * + * @example + * ```typescript + * const poolType: PoolType = "BurnMintTokenPool"; + * poolTypeSchema.parse(poolType); // Valid + * + * poolTypeSchema.parse("InvalidType"); // Throws ZodError + * ``` + * + * @public + */ export const poolTypeSchema = z.enum(['BurnMintTokenPool', 'LockReleaseTokenPool']); -// Schema for rate limiter configuration +/** + * Zod schema for rate limiter configuration. + * + * Defines token bucket rate limiter parameters that control the rate and capacity + * of token transfers to/from a specific chain. + * + * @remarks + * Token Bucket Algorithm: + * - **isEnabled**: Enable/disable the rate limiter + * - **capacity**: Maximum tokens in bucket (burst limit) as string wei + * - **rate**: Token refill rate per second as string wei/sec + * + * Amount Format: + * - Values are strings to avoid JavaScript number precision issues + * - Represent token amounts in smallest unit (wei) + * + * @example + * ```typescript + * const config: RateLimiterConfig = { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens max + * rate: "100000000000000000" // 0.1 tokens/sec refill + * }; + * + * rateLimiterConfigSchema.parse(config); // Valid + * ``` + * + * @public + */ export const rateLimiterConfigSchema = z.object({ + /** Enable/disable the rate limiter */ isEnabled: z.boolean(), - capacity: z.string(), // BigNumber as string - rate: z.string(), // BigNumber as string + + /** Maximum token amount in bucket (wei as string) */ + capacity: z.string(), + + /** Token refill rate per second (wei/sec as string) */ + rate: z.string(), }); -// Schema for remote chain configuration +/** + * Zod schema for remote chain configuration. + * + * Defines the infrastructure addresses and parameters for a remote blockchain + * in the CCIP network. + * + * @remarks + * These addresses are chain-specific CCIP infrastructure components: + * - **remotePoolFactory**: TokenPoolFactory contract on remote chain + * - **remoteRouter**: CCIP Router contract address + * - **remoteRMNProxy**: Risk Management Network proxy address + * - **remoteTokenDecimals**: Token decimals on remote chain (may differ) + * + * @example + * ```typescript + * const chainConfig: RemoteChainConfig = { + * remotePoolFactory: "0x1234567890123456789012345678901234567890", + * remoteRouter: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * remoteRMNProxy: "0x9876543210987654321098765432109876543210", + * remoteTokenDecimals: 18 + * }; + * + * remoteChainConfigSchema.parse(chainConfig); // Valid + * ``` + * + * @public + */ export const remoteChainConfigSchema = z.object({ + /** TokenPoolFactory address on remote chain */ remotePoolFactory: z.string(), + + /** CCIP Router address on remote chain */ remoteRouter: z.string(), + + /** Risk Management Network proxy address on remote chain */ remoteRMNProxy: z.string(), + + /** Token decimals on remote chain (may differ from local) */ remoteTokenDecimals: z.number(), }); -// Schema for remote token pool information (user input) +/** + * Zod schema for remote token pool information. + * + * Defines complete configuration for a token pool on a remote chain, including + * pool address, token address, deployment bytecode, and rate limiter settings. + * + * @remarks + * This schema is used for configuring cross-chain token transfers. Each remote + * chain requires its own pool and token addresses. + * + * Init Code: + * - remotePoolInitCode: Bytecode for deploying the pool on remote chain + * - remoteTokenInitCode: Bytecode for deploying the token on remote chain + * + * @example + * ```typescript + * const remotePool: RemoteTokenPoolInfo = { + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * remotePoolAddress: "0x1234567890123456789012345678901234567890", + * remotePoolInitCode: "0x608060...", + * remoteChainConfig: { + * remotePoolFactory: "0xFactory...", + * remoteRouter: "0xRouter...", + * remoteRMNProxy: "0xRMN...", + * remoteTokenDecimals: 18 + * }, + * poolType: "BurnMintTokenPool", + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * remoteTokenInitCode: "0x608060...", + * rateLimiterConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * } + * }; + * + * remoteTokenPoolInfoSchema.parse(remotePool); // Valid + * ``` + * + * @public + */ export const remoteTokenPoolInfoSchema = z.object({ - remoteChainSelector: z.string(), // BigNumber as string + /** Chain selector identifying the remote blockchain (uint64 as string) */ + remoteChainSelector: z.string(), + + /** Address of the pool contract on remote chain */ remotePoolAddress: z.string(), + + /** Deployment bytecode for remote pool contract */ remotePoolInitCode: z.string(), + + /** Remote chain infrastructure configuration */ remoteChainConfig: remoteChainConfigSchema, + + /** Type of pool on remote chain */ poolType: poolTypeSchema, + + /** Address of the token contract on remote chain */ remoteTokenAddress: z.string(), + + /** Deployment bytecode for remote token contract */ remoteTokenInitCode: z.string(), + + /** Rate limiter configuration for this chain connection */ rateLimiterConfig: rateLimiterConfigSchema, }); -// Schema for pool deployment parameters (matches contract function parameters) +/** + * Zod schema for pool deployment parameters. + * + * Defines the parameters required to deploy a TokenPool for an existing token + * via the TokenPoolFactory. + * + * @remarks + * Validation Rules: + * - **token**: Valid Ethereum address of existing token contract + * - **decimals**: Token decimals (must match token's decimals() value) + * - **remoteTokenPools**: Optional array of remote chain configurations + * - **poolType**: BurnMintTokenPool or LockReleaseTokenPool + * + * Pool Type Selection: + * - Check if token has mint() and burn() functions + * - If yes: Use BurnMintTokenPool + * - If no: Use LockReleaseTokenPool + * + * @example + * ```typescript + * // Deploy BurnMintTokenPool for existing token + * const params: PoolDeploymentParams = { + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * decimals: 18, + * poolType: "BurnMintTokenPool", + * remoteTokenPools: [] // No remote chains initially + * }; + * + * poolDeploymentParamsSchema.parse(params); // Valid + * ``` + * + * @example + * ```typescript + * // Deploy LockReleaseTokenPool with remote chain + * const params = { + * token: "0x779877A7B0D9E8603169DdbD7836e478b4624789", + * decimals: 6, + * poolType: "LockReleaseTokenPool", + * remoteTokenPools: [{ + * remoteChainSelector: "10344971235874465080", // Base Sepolia + * remotePoolAddress: "0x1234...", + * remotePoolInitCode: "0x...", + * remoteChainConfig: { ... }, + * poolType: "LockReleaseTokenPool", + * remoteTokenAddress: "0xabcd...", + * remoteTokenInitCode: "0x...", + * rateLimiterConfig: { isEnabled: true, capacity: "1000", rate: "100" } + * }] + * }; + * + * poolDeploymentParamsSchema.parse(params); // Valid + * ``` + * + * @example + * ```typescript + * // Validation error for invalid address + * try { + * const invalid = { + * token: "0xInvalidAddress", // Invalid address + * decimals: 18, + * poolType: "BurnMintTokenPool" + * }; + * poolDeploymentParamsSchema.parse(invalid); + * } catch (error) { + * console.error(error.message); // "Invalid Ethereum address format for token" + * } + * ``` + * + * @see {@link PoolDeploymentParams} for inferred TypeScript type + * @see {@link createPoolDeploymentGenerator} for usage in generator + * + * @public + */ export const poolDeploymentParamsSchema = z.object({ + /** Ethereum address of existing token contract - validated format */ token: z.string().refine((address: string): boolean => ethers.isAddress(address), { message: 'Invalid Ethereum address format for token', }), + + /** Token decimals (must match token.decimals()) */ decimals: z.number(), + + /** Optional array of remote chain pool configurations */ remoteTokenPools: z.array(remoteTokenPoolInfoSchema).optional().default([]), + + /** Pool type determining transfer behavior */ poolType: poolTypeSchema, }); +/** + * TypeScript type for pool type enum values. + * @public + */ export type PoolType = z.infer; + +/** + * TypeScript type for rate limiter configuration. + * @public + */ export type RateLimiterConfig = z.infer; + +/** + * TypeScript type for remote chain configuration. + * @public + */ export type RemoteChainConfig = z.infer; + +/** + * TypeScript type for remote token pool information. + * @public + */ export type RemoteTokenPoolInfo = z.infer; + +/** + * TypeScript type for validated pool deployment parameters. + * + * @example + * ```typescript + * function deployPool(params: PoolDeploymentParams) { + * console.log(`Deploying ${params.poolType} for token ${params.token}`); + * } + * + * const validated = poolDeploymentParamsSchema.parse(userInput); + * deployPool(validated); + * ``` + * + * @public + */ export type PoolDeploymentParams = z.infer; -// Contract-specific types (with numeric pool type) +/** + * Contract-specific type with numeric pool type. + * + * Used internally when encoding function calls for the smart contract, where + * pool type is represented as a number (0 = BurnMintTokenPool, 1 = LockReleaseTokenPool). + * + * @remarks + * This type is used by poolTypeConverter to transform string pool types to + * contract-compatible numeric values before encoding. + * + * @see {@link poolTypeConverter} for string-to-number conversion + * + * @internal + */ export type ContractRemoteTokenPoolInfo = Omit & { poolType: number; }; diff --git a/src/types/rateLimiter.ts b/src/types/rateLimiter.ts new file mode 100644 index 0000000..60d7175 --- /dev/null +++ b/src/types/rateLimiter.ts @@ -0,0 +1,250 @@ +/** + * @fileoverview Type definitions and validation schemas for TokenPool rate limiter configuration. + * + * This module defines the input parameters and validation rules for configuring rate limiters + * on TokenPool cross-chain connections. Rate limiters use a token bucket algorithm to control + * the rate and capacity of token transfers between chains. + * + * Each chain connection has two independent rate limiters: + * - Outbound: Controls tokens sent FROM this pool TO the remote chain + * - Inbound: Controls tokens received FROM remote chain TO this pool + * + * @module types/rateLimiter + */ + +import { z } from 'zod'; +import { SafeMetadata } from './safe'; + +/** + * Zod schema for rate limiter configuration. + * + * Defines token bucket rate limiter parameters that control the rate and capacity + * of token transfers in one direction (either inbound or outbound). + * + * @remarks + * Token Bucket Algorithm: + * - **isEnabled**: Enable/disable the rate limiter + * - **capacity**: Maximum tokens in bucket (burst limit) as string wei + * - **rate**: Token refill rate per second as string wei/sec + * + * Amount Format: + * - Values are strings to avoid JavaScript number precision issues + * - Represent token amounts in smallest unit (wei) + * - Example: "1000000000000000000" = 1 token with 18 decimals + * + * Token Bucket Mechanics: + * - Bucket starts full (at capacity) + * - Each transfer consumes tokens from bucket + * - Bucket refills at constant rate per second + * - Transfer fails if bucket has insufficient tokens + * - Disabled rate limiter = unlimited transfers + * + * @example + * ```typescript + * const config: RateLimiterConfig = { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens max burst + * rate: "100000000000000000" // 0.1 tokens/sec refill + * }; + * + * rateLimiterConfigSchema.parse(config); // Valid + * ``` + * + * @example + * ```typescript + * // Disabled rate limiter (no restrictions) + * const disabled: RateLimiterConfig = { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * }; + * ``` + * + * @see {@link setChainRateLimiterConfigSchema} for full chain rate limiter configuration + * + * @public + */ +export const rateLimiterConfigSchema = z.object({ + /** Enable/disable the rate limiter */ + isEnabled: z.boolean(), + + /** Maximum token amount in bucket (wei as string) */ + capacity: z.string(), + + /** Token refill rate per second (wei/sec as string) */ + rate: z.string(), +}); + +/** + * TypeScript type for rate limiter configuration. + * @public + */ +export type RateLimiterConfig = z.infer; + +/** + * Zod schema for setting chain-specific rate limiter configuration. + * + * Defines the parameters for configuring both inbound and outbound rate limiters + * for a specific remote chain connection on a TokenPool. + * + * @remarks + * Validation Rules: + * - **remoteChainSelector**: Chain selector identifying the remote blockchain (uint64 as string) + * - **outboundConfig**: Rate limiter for tokens sent TO remote chain + * - **inboundConfig**: Rate limiter for tokens received FROM remote chain + * + * Bidirectional Rate Limiting: + * - Each chain connection has TWO independent rate limiters + * - Outbound limiter: Controls tokens leaving this pool (local → remote) + * - Inbound limiter: Controls tokens entering this pool (remote → local) + * - Limiters can have different capacities and rates + * - Limiters can be independently enabled/disabled + * + * Use Cases: + * - Asymmetric limits (higher inbound than outbound, or vice versa) + * - Temporary restrictions (disable temporarily, re-enable later) + * - Gradual capacity increases (start conservative, increase over time) + * - Emergency controls (disable transfers during incidents) + * + * @example + * ```typescript + * // Symmetric rate limiting (same inbound and outbound) + * const symmetric: SetChainRateLimiterConfigInput = { + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * outboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens + * rate: "100000000000000000" // 0.1 tokens/sec + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "1000000000000000000000", // 1000 tokens + * rate: "100000000000000000" // 0.1 tokens/sec + * } + * }; + * + * setChainRateLimiterConfigSchema.parse(symmetric); // Valid + * ``` + * + * @example + * ```typescript + * // Asymmetric rate limiting (higher inbound capacity) + * const asymmetric: SetChainRateLimiterConfigInput = { + * remoteChainSelector: "10344971235874465080", // Base Sepolia + * outboundConfig: { + * isEnabled: true, + * capacity: "5000000000000000000000", // 5000 tokens outbound + * rate: "500000000000000000" + * }, + * inboundConfig: { + * isEnabled: true, + * capacity: "10000000000000000000000", // 10000 tokens inbound (2x) + * rate: "1000000000000000000" + * } + * }; + * + * setChainRateLimiterConfigSchema.parse(asymmetric); // Valid + * ``` + * + * @example + * ```typescript + * // Disable outbound, enable inbound only + * const inboundOnly: SetChainRateLimiterConfigInput = { + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: false, // Outbound disabled (unrestricted) + * capacity: "0", + * rate: "0" + * }, + * inboundConfig: { + * isEnabled: true, // Inbound enabled (restricted) + * capacity: "1000000000000000000000", + * rate: "100000000000000000" + * } + * }; + * ``` + * + * @example + * ```typescript + * // Disable all rate limiting (emergency override) + * const emergency: SetChainRateLimiterConfigInput = { + * remoteChainSelector: "16015286601757825753", + * outboundConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * }, + * inboundConfig: { + * isEnabled: false, + * capacity: "0", + * rate: "0" + * } + * }; + * ``` + * + * @see {@link SetChainRateLimiterConfigInput} for inferred TypeScript type + * @see {@link createRateLimiterConfigGenerator} for usage in generator + * + * @public + */ +export const setChainRateLimiterConfigSchema = z.object({ + /** Chain selector identifying the remote blockchain (uint64 as string) */ + remoteChainSelector: z.string(), + + /** Rate limiter configuration for outbound transfers (TO remote chain) */ + outboundConfig: rateLimiterConfigSchema, + + /** Rate limiter configuration for inbound transfers (FROM remote chain) */ + inboundConfig: rateLimiterConfigSchema, +}); + +/** + * TypeScript type for validated chain rate limiter configuration. + * + * @example + * ```typescript + * function setRateLimiter(params: SetChainRateLimiterConfigInput) { + * console.log(`Configuring rate limiter for chain ${params.remoteChainSelector}`); + * console.log(`Outbound enabled: ${params.outboundConfig.isEnabled}`); + * console.log(`Inbound enabled: ${params.inboundConfig.isEnabled}`); + * } + * + * const validated = setChainRateLimiterConfigSchema.parse(userInput); + * setRateLimiter(validated); + * ``` + * + * @public + */ +export type SetChainRateLimiterConfigInput = z.infer; + +/** + * Safe Transaction Builder metadata for rate limiter operations. + * + * Extends SafeMetadata with TokenPool address information required for formatting + * rate limiter configuration transactions as Safe Transaction Builder JSON. + * + * @remarks + * This metadata is used by the rate limiter formatter to generate Safe Transaction Builder + * JSON files for configuring pool rate limiters via multisig. + * + * @example + * ```typescript + * const metadata: SafeRateLimiterMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * ownerAddress: "0x0000000000000000000000000000000000000000", + * tokenPoolAddress: "0x1234567890123456789012345678901234567890" // TokenPool + * }; + * + * const safeJson = await formatter.formatAsSafeJson(rateLimiterTx, metadata); + * ``` + * + * @see {@link SafeMetadata} for base metadata fields + * @see {@link RateLimiterFormatter} for usage in formatting + * + * @public + */ +export interface SafeRateLimiterMetadata extends SafeMetadata { + /** Address of the TokenPool contract to configure */ + tokenPoolAddress: string; +} diff --git a/src/types/safe.ts b/src/types/safe.ts index 4203141..b0490d8 100644 --- a/src/types/safe.ts +++ b/src/types/safe.ts @@ -1,76 +1,544 @@ /** - * Safe Transaction Builder version + * @fileoverview Type definitions for Gnosis Safe Transaction Builder JSON format. + * + * This module defines the type structures for generating Safe Transaction Builder JSON + * files. These JSON files can be imported into the Safe Transaction Builder UI to create + * multisig transactions for TokenPool operations. + * + * Safe Transaction Builder is a web interface for creating, signing, and executing + * multisig transactions on Gnosis Safe contracts. This module ensures compatibility + * with the Transaction Builder's JSON import format. + * + * @see {@link https://help.safe.global/en/articles/40841-transaction-builder} Safe Transaction Builder Documentation + * + * @module types/safe + */ + +/** + * Safe Transaction Builder version string. + * + * Specifies the version of the Safe Transaction Builder JSON format. This version + * must match the format expected by the Safe Transaction Builder UI. + * + * @remarks + * Version 1.18.0 is compatible with Safe Transaction Builder as of 2024. + * Update this constant if the Safe Transaction Builder format changes. + * + * @example + * ```typescript + * const safeJson: SafeTransactionBuilderJSON = { + * version: SAFE_TX_BUILDER_VERSION, // "1.18.0" + * chainId: "1", + * // ... + * }; + * ``` + * + * @public */ export const SAFE_TX_BUILDER_VERSION = '1.18.0'; /** - * Safe operation type - * 0 = Call - * 1 = DelegateCall + * Safe operation type enumeration. + * + * Defines the execution mode for Safe transactions. Determines how the Safe contract + * executes the transaction. + * + * @remarks + * Operation Types: + * - **Call (0)**: Standard external call (most common) + * - Executes transaction as a normal external call + * - Used for token transfers, contract interactions, etc. + * - Preserves msg.sender as the Safe contract address + * + * - **DelegateCall (1)**: Delegate call (advanced use cases) + * - Executes transaction in the context of the Safe contract + * - Target contract's code runs with Safe's storage + * - Used for upgrades, library calls, etc. + * - ⚠️ **Security Risk**: Target can modify Safe storage + * + * For TokenPool operations, always use `SafeOperationType.Call`. + * + * @example + * ```typescript + * // Standard transaction (recommended) + * const standardTx: SafeTransactionDataBase = { + * to: "0x1234567890123456789012345678901234567890", + * value: "0", + * data: "0x...", + * operation: SafeOperationType.Call // Use Call for normal operations + * }; + * ``` + * + * @example + * ```typescript + * // DelegateCall (advanced, use with caution) + * const delegateTx: SafeTransactionDataBase = { + * to: "0xLibraryAddress", + * value: "0", + * data: "0x...", + * operation: SafeOperationType.DelegateCall // Advanced use only + * }; + * ``` + * + * @public */ export enum SafeOperationType { + /** Standard external call (recommended for TokenPool operations) */ Call = 0, + /** Delegate call (advanced use cases, modifies Safe storage) */ DelegateCall = 1, } /** - * Base Safe transaction data structure + * Base Safe transaction data structure. + * + * Defines the core transaction parameters required for any Safe transaction. + * This is the minimal set of fields needed to execute a transaction via a Safe contract. + * + * @remarks + * Field Descriptions: + * - **to**: Destination address (contract or EOA) + * - **value**: ETH amount to send (wei as string, usually "0" for contract calls) + * - **data**: ABI-encoded function call data + * - **operation**: Execution mode (Call or DelegateCall) + * + * This interface is extended by SafeTransactionBuilderTransaction to add metadata + * fields required by the Transaction Builder UI. + * + * @example + * ```typescript + * // Token pool chain update transaction + * const txData: SafeTransactionDataBase = { + * to: "0x1234567890123456789012345678901234567890", // TokenPool address + * value: "0", // No ETH sent + * data: "0x8dc97c2f...", // applyChainUpdates calldata + * operation: SafeOperationType.Call + * }; + * ``` + * + * @example + * ```typescript + * // ETH transfer transaction + * const ethTransfer: SafeTransactionDataBase = { + * to: "0xReceiver", + * value: "1000000000000000000", // 1 ETH in wei + * data: "0x", // Empty data for ETH transfer + * operation: SafeOperationType.Call + * }; + * ``` + * + * @see {@link SafeTransactionBuilderTransaction} for full Transaction Builder format + * + * @public */ export interface SafeTransactionDataBase { + /** Destination contract or account address */ to: string; + + /** ETH amount to send in wei (string to avoid precision issues) */ value: string; + + /** ABI-encoded function call data (hex string starting with 0x) */ data: string; + + /** Transaction execution mode (Call or DelegateCall) */ operation: SafeOperationType; } /** - * Safe Transaction Builder method interface + * Safe Transaction Builder method metadata. + * + * Describes the contract method being called in a Safe transaction, including + * the method signature and input parameter definitions. Used by the Transaction + * Builder UI to display human-readable transaction information. + * + * @remarks + * This structure mirrors the ABI format for function definitions: + * - **inputs**: Array of parameter definitions (name, type, internalType) + * - **name**: Function name (e.g., "applyChainUpdates") + * - **payable**: Whether the function accepts ETH (false for TokenPool operations) + * + * The Transaction Builder uses this metadata to: + * 1. Display the method name and parameters in the UI + * 2. Validate the encoded data matches the signature + * 3. Show parameter values in a readable format + * + * @example + * ```typescript + * // applyChainUpdates method metadata + * const method: SafeTransactionBuilderMethod = { + * inputs: [ + * { + * name: "chainsToRemove", + * type: "uint64[]", + * internalType: "uint64[]" + * }, + * { + * name: "chainsToAdd", + * type: "tuple[]", + * internalType: "struct TokenPool.ChainUpdate[]" + * } + * ], + * name: "applyChainUpdates", + * payable: false + * }; + * ``` + * + * @example + * ```typescript + * // mint method metadata + * const mintMethod: SafeTransactionBuilderMethod = { + * inputs: [ + * { + * name: "to", + * type: "address", + * internalType: "address" + * }, + * { + * name: "amount", + * type: "uint256", + * internalType: "uint256" + * } + * ], + * name: "mint", + * payable: false + * }; + * ``` + * + * @see {@link SafeTransactionBuilderTransaction} for usage in transactions + * + * @public */ export interface SafeTransactionBuilderMethod { + /** Array of input parameter definitions matching ABI format */ inputs: Array<{ + /** Parameter name (e.g., "chainsToRemove") */ name: string; + /** Solidity type (e.g., "uint64[]", "address") */ type: string; + /** Internal Solidity type with struct names (e.g., "struct TokenPool.ChainUpdate[]") */ internalType: string; }>; + + /** Function name (e.g., "applyChainUpdates") */ name: string; + + /** Whether the function accepts ETH (false for TokenPool operations) */ payable: boolean; } /** - * Safe Transaction Builder transaction interface + * Safe Transaction Builder transaction structure. + * + * Complete transaction format for Safe Transaction Builder JSON files. Extends + * the base transaction data with method metadata and decoded parameter values. + * + * @remarks + * This is the primary transaction format used in SafeTransactionBuilderJSON. + * Each transaction includes: + * 1. Base transaction data (to, value, data, operation) + * 2. Method signature metadata (contractMethod) + * 3. Decoded parameter values (contractInputsValues) + * + * The Transaction Builder UI uses these fields to: + * - Display transaction details in a human-readable format + * - Validate the transaction data matches the method signature + * - Allow users to review parameter values before signing + * + * The contractInputsValues field can be null if parameter decoding is not needed + * or if the transaction data is manually constructed. + * + * @example + * ```typescript + * // Chain update transaction with full metadata + * const transaction: SafeTransactionBuilderTransaction = { + * to: "0x1234567890123456789012345678901234567890", + * value: "0", + * data: "0x8dc97c2f...", + * operation: SafeOperationType.Call, + * contractMethod: { + * inputs: [ + * { name: "chainsToRemove", type: "uint64[]", internalType: "uint64[]" }, + * { name: "chainsToAdd", type: "tuple[]", internalType: "struct TokenPool.ChainUpdate[]" } + * ], + * name: "applyChainUpdates", + * payable: false + * }, + * contractInputsValues: { + * chainsToRemove: [], + * chainsToAdd: [ + * { + * remoteChainSelector: "16015286601757825753", + * remotePoolAddresses: ["0xPool"], + * // ... + * } + * ] + * } + * }; + * ``` + * + * @example + * ```typescript + * // Simple transaction without decoded values + * const simpleTx: SafeTransactionBuilderTransaction = { + * to: "0xToken", + * value: "0", + * data: "0x40c10f19...", + * operation: SafeOperationType.Call, + * contractMethod: { + * inputs: [ + * { name: "to", type: "address", internalType: "address" }, + * { name: "amount", type: "uint256", internalType: "uint256" } + * ], + * name: "mint", + * payable: false + * }, + * contractInputsValues: null // No decoded values provided + * }; + * ``` + * + * @see {@link SafeTransactionBuilderJSON} for complete JSON structure + * + * @public */ export interface SafeTransactionBuilderTransaction extends SafeTransactionDataBase { + /** Method signature and parameter metadata */ contractMethod: SafeTransactionBuilderMethod; + + /** Decoded parameter values (null if not decoded) */ contractInputsValues: Record | null; } /** - * Safe Transaction Builder metadata interface + * Safe Transaction Builder metadata. + * + * Provides descriptive metadata about a batch of Safe transactions, including + * the batch name, description, creator information, and builder version. + * + * @remarks + * This metadata is displayed in the Safe Transaction Builder UI when the JSON + * file is imported, helping users understand the purpose and origin of the + * transaction batch. + * + * Field Purposes: + * - **name**: Short title for the transaction batch (e.g., "Deploy Token and Pool") + * - **description**: Detailed explanation of what the transactions do + * - **txBuilderVersion**: Safe Transaction Builder version (use SAFE_TX_BUILDER_VERSION) + * - **createdFromSafeAddress**: The Safe contract address + * - **createdFromOwnerAddress**: The Safe owner who created the transactions + * + * @example + * ```typescript + * const meta: SafeTransactionBuilderMeta = { + * name: "Deploy Cross-Chain Token", + * description: "Deploy BurnMintERC20 token with TokenPool and configure Base Sepolia connection", + * txBuilderVersion: "1.18.0", + * createdFromSafeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * createdFromOwnerAddress: "0x0000000000000000000000000000000000000000" + * }; + * ``` + * + * @example + * ```typescript + * // Chain update metadata + * const chainUpdateMeta: SafeTransactionBuilderMeta = { + * name: "Add Ethereum Sepolia Chain", + * description: "Configure TokenPool to support cross-chain transfers to/from Ethereum Sepolia", + * txBuilderVersion: SAFE_TX_BUILDER_VERSION, + * createdFromSafeAddress: "0xYourSafe", + * createdFromOwnerAddress: "0xYourOwner" + * }; + * ``` + * + * @see {@link SafeTransactionBuilderJSON} for usage in complete JSON structure + * + * @public */ export interface SafeTransactionBuilderMeta { + /** Short title for the transaction batch */ name: string; + + /** Detailed description of what the transactions do */ description: string; + + /** Safe Transaction Builder version (use SAFE_TX_BUILDER_VERSION constant) */ txBuilderVersion: string; + + /** Address of the Safe contract */ createdFromSafeAddress: string; + + /** Address of the Safe owner who created the transactions */ createdFromOwnerAddress: string; } /** - * Safe Transaction Builder JSON interface + * Safe Transaction Builder complete JSON structure. + * + * Root structure for Safe Transaction Builder JSON files. This format can be + * imported into the Safe Transaction Builder UI to create multisig transactions. + * + * @remarks + * JSON Structure: + * - **version**: Format version (use SAFE_TX_BUILDER_VERSION) + * - **chainId**: Chain ID where transactions will be executed + * - **createdAt**: Unix timestamp (milliseconds) when JSON was created + * - **meta**: Metadata describing the transaction batch + * - **transactions**: Array of transactions to execute + * + * Workflow: + * 1. Generate SafeTransactionBuilderJSON using formatters + * 2. Write JSON to file (e.g., `output/token-deployment.json`) + * 3. Import file into Safe Transaction Builder UI + * 4. Review transactions in UI + * 5. Sign and execute via Safe multisig + * + * @example + * ```typescript + * // Complete Safe JSON for token deployment + * const safeJson: SafeTransactionBuilderJSON = { + * version: "1.18.0", + * chainId: "84532", // Base Sepolia + * createdAt: Date.now(), + * meta: { + * name: "Deploy Token and Pool", + * description: "Deploy BurnMintERC20 token with cross-chain pool", + * txBuilderVersion: "1.18.0", + * createdFromSafeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * createdFromOwnerAddress: "0x0000000000000000000000000000000000000000" + * }, + * transactions: [ + * { + * to: "0x17d8a409fe2cef2d3808bcb61f14abeffc28876e", // TokenPoolFactory + * value: "0", + * data: "0x...", + * operation: SafeOperationType.Call, + * contractMethod: { + * inputs: [...], + * name: "deployTokenAndTokenPool", + * payable: false + * }, + * contractInputsValues: null + * } + * ] + * }; + * ``` + * + * @example + * ```typescript + * // Multi-transaction batch (grant roles after deployment) + * const batchJson: SafeTransactionBuilderJSON = { + * version: "1.18.0", + * chainId: "84532", + * createdAt: Date.now(), + * meta: { + * name: "Grant Pool Roles", + * description: "Grant mint and burn roles to TokenPool", + * txBuilderVersion: "1.18.0", + * createdFromSafeAddress: "0xYourSafe", + * createdFromOwnerAddress: "0xYourOwner" + * }, + * transactions: [ + * { + * to: "0xTokenAddress", + * value: "0", + * data: "0x...", // grantMintAndBurn + * operation: SafeOperationType.Call, + * contractMethod: { ... }, + * contractInputsValues: null + * }, + * { + * to: "0xPoolAddress", + * value: "0", + * data: "0x...", // applyChainUpdates + * operation: SafeOperationType.Call, + * contractMethod: { ... }, + * contractInputsValues: null + * } + * ] + * }; + * ``` + * + * @see {@link SAFE_TX_BUILDER_VERSION} for format version + * @see {@link SafeTransactionBuilderTransaction} for transaction structure + * @see {@link SafeTransactionBuilderMeta} for metadata structure + * + * @public */ export interface SafeTransactionBuilderJSON { + /** Safe Transaction Builder format version */ version: string; + + /** Chain ID where transactions will be executed */ chainId: string; + + /** Unix timestamp (milliseconds) when JSON was created */ createdAt: number; + + /** Transaction batch metadata (name, description, creator info) */ meta: SafeTransactionBuilderMeta; + + /** Array of transactions to execute via Safe */ transactions: SafeTransactionBuilderTransaction[]; } /** - * Safe metadata for transaction generation + * Safe metadata for transaction generation. + * + * Minimal metadata required to generate Safe transactions. This is the input + * format used by generators and formatters to create SafeTransactionBuilderJSON. + * + * @remarks + * This is a simplified metadata structure containing only the essential fields + * needed during transaction generation. Formatters expand this into the full + * SafeTransactionBuilderMeta and SafeTransactionBuilderJSON structures. + * + * @example + * ```typescript + * // Metadata passed to formatters + * const metadata: SafeMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * ownerAddress: "0x0000000000000000000000000000000000000000" + * }; + * + * // Formatter expands this into full SafeTransactionBuilderJSON + * const formatted = await formatter.formatAsSafeJson( + * transaction, + * metadata + * ); + * ``` + * + * @example + * ```typescript + * // CLI passes metadata from command-line flags + * function handleGenerateCommand( + * safeAddress: string, + * ownerAddress: string, + * chainId: string + * ) { + * const metadata: SafeMetadata = { + * chainId, + * safeAddress, + * ownerAddress + * }; + * + * const safeJson = await formatter.formatAsSafeJson(tx, metadata); + * await outputWriter.writeOutput(safeJson, 'safe-json', outputPath); + * } + * ``` + * + * @see {@link SafeTransactionBuilderMeta} for full metadata structure + * @see {@link SafeTransactionBuilderJSON} for complete JSON format + * + * @public */ export interface SafeMetadata { + /** Chain ID where transaction will be executed */ chainId: string; + + /** Address of the Safe multisig contract */ safeAddress: string; + + /** Address of the Safe owner executing the transaction */ ownerAddress: string; } diff --git a/src/types/tokenDeployment.ts b/src/types/tokenDeployment.ts index 53e92d9..24ec08d 100644 --- a/src/types/tokenDeployment.ts +++ b/src/types/tokenDeployment.ts @@ -1,14 +1,153 @@ +/** + * @fileoverview Type definitions and validation schemas for token deployment operations. + * + * This module defines the input parameters and validation rules for deploying a new + * BurnMintERC20 token and its associated TokenPool via the TokenPoolFactory. Uses Zod + * for runtime type validation and TypeScript type inference. + * + * @module types/tokenDeployment + */ + import { z } from 'zod'; import { remoteTokenPoolInfoSchema } from './poolDeployment'; -// Schema for BurnMintERC20 constructor parameters +/** + * Zod validation schema for token deployment parameters. + * + * Defines the structure and validation rules for deploying a new BurnMintERC20 token + * with an associated TokenPool. All fields are validated at runtime using Zod. + * + * @remarks + * Validation Rules: + * - **name**: Non-empty string (e.g., "My Token") + * - **symbol**: Non-empty string (e.g., "MTK") + * - **decimals**: Number between 0 and 18 (standard: 18 for Ether-like, 6 for USDC-like) + * - **maxSupply**: String representation of maximum token supply in wei (prevents overflow) + * - **preMint**: String representation of initial mint amount in wei + * - **remoteTokenPools**: Optional array of remote chain configurations (defaults to empty) + * + * Amount Format: + * - Amounts are strings to avoid JavaScript number precision issues + * - Represent values in smallest unit (wei) + * - Example: "1000000000000000000" = 1 token with 18 decimals + * + * @example + * ```typescript + * // Valid token deployment input + * const validInput = { + * name: "CrossChainToken", + * symbol: "CCT", + * decimals: 18, + * maxSupply: "1000000000000000000000000", // 1 million tokens + * preMint: "100000000000000000000", // 100 tokens pre-minted + * remoteTokenPools: [] // No remote chains initially + * }; + * + * // Validate with Zod + * const result = tokenDeploymentParamsSchema.parse(validInput); + * ``` + * + * @example + * ```typescript + * // Token deployment with remote chain configuration + * const inputWithRemote = { + * name: "MultiChainToken", + * symbol: "MCT", + * decimals: 18, + * maxSupply: "1000000000000000000000000", + * preMint: "0", + * remoteTokenPools: [{ + * remoteChainSelector: "16015286601757825753", // Ethereum Sepolia + * remotePoolAddress: "0x1234567890123456789012345678901234567890", + * remoteTokenAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + * poolType: "BurnMintTokenPool" + * }] + * }; + * + * const result = tokenDeploymentParamsSchema.parse(inputWithRemote); + * ``` + * + * @example + * ```typescript + * // USDC-like token (6 decimals) + * const usdcLikeToken = { + * name: "USD Coin", + * symbol: "USDC", + * decimals: 6, + * maxSupply: "1000000000000", // 1 million USDC (6 decimals) + * preMint: "100000000", // 100 USDC pre-minted + * remoteTokenPools: [] + * }; + * + * const result = tokenDeploymentParamsSchema.parse(usdcLikeToken); + * ``` + * + * @example + * ```typescript + * // Validation error handling + * try { + * const invalidInput = { + * name: "", // Invalid: empty string + * symbol: "MTK", + * decimals: 25, // Invalid: exceeds 18 + * maxSupply: "1000", + * preMint: "0" + * }; + * tokenDeploymentParamsSchema.parse(invalidInput); + * } catch (error) { + * if (error instanceof z.ZodError) { + * console.error('Validation failed:', error.errors); + * // [ + * // { path: ['name'], message: 'Token name cannot be empty' }, + * // { path: ['decimals'], message: 'Number must be less than or equal to 18' } + * // ] + * } + * } + * ``` + * + * @see {@link TokenDeploymentParams} for the inferred TypeScript type + * @see {@link remoteTokenPoolInfoSchema} for remote chain configuration schema + * @see {@link createTokenDeploymentGenerator} for usage in generator + * + * @public + */ export const tokenDeploymentParamsSchema = z.object({ - name: z.string(), - symbol: z.string(), - decimals: z.number(), - maxSupply: z.string(), // BigNumber as string - preMint: z.string(), // BigNumber as string + /** Token name (e.g., "My Token") - must be non-empty */ + name: z.string().min(1, 'Token name cannot be empty'), + + /** Token symbol (e.g., "MTK") - must be non-empty */ + symbol: z.string().min(1, 'Token symbol cannot be empty'), + + /** Token decimals (0-18) - standard is 18 for most tokens, 6 for USDC-like */ + decimals: z.number().min(0).max(18), + + /** Maximum token supply in wei as string to avoid precision issues */ + maxSupply: z.string(), + + /** Initial mint amount in wei as string - tokens minted to deployer */ + preMint: z.string(), + + /** Optional array of remote chain pool configurations - defaults to empty array */ remoteTokenPools: z.array(remoteTokenPoolInfoSchema).optional().default([]), }); +/** + * TypeScript type inferred from tokenDeploymentParamsSchema. + * + * Represents validated token deployment parameters after Zod schema validation. + * Use this type for function parameters and return types after validation. + * + * @example + * ```typescript + * function deployToken(params: TokenDeploymentParams) { + * // params is guaranteed to match the schema + * console.log(`Deploying ${params.name} (${params.symbol})`); + * } + * + * const validated = tokenDeploymentParamsSchema.parse(userInput); + * deployToken(validated); + * ``` + * + * @public + */ export type TokenDeploymentParams = z.infer; diff --git a/src/types/tokenMint.ts b/src/types/tokenMint.ts new file mode 100644 index 0000000..561e3f9 --- /dev/null +++ b/src/types/tokenMint.ts @@ -0,0 +1,342 @@ +/** + * @fileoverview Type definitions and validation schemas for token minting and role management. + * + * This module defines the input parameters and validation rules for: + * - Token minting operations (requires minter role) + * - Role management (granting/revoking mint and burn roles) + * + * These operations are performed on FactoryBurnMintERC20 tokens after deployment. + * Role management is essential for enabling TokenPools to mint and burn tokens during + * cross-chain transfers. + * + * @module types/tokenMint + */ + +import { z } from 'zod'; +import { ethers } from 'ethers'; +import { SafeMetadata } from './safe'; + +/** + * Zod schema for token mint parameters. + * + * Defines the parameters required to mint tokens to a receiver address. + * The minter must have the MINTER_ROLE granted on the token contract. + * + * @remarks + * Validation Rules: + * - **receiver**: Valid Ethereum address (checksummed or lowercase) + * - **amount**: Token amount as string in wei to avoid JavaScript precision issues + * + * Amount Format: + * - String representation of amount in smallest unit (wei) + * - Example: "1000000000000000000" = 1 token with 18 decimals + * - Example: "1000000" = 1 USDC with 6 decimals + * + * Permission Requirements: + * - Transaction sender (Safe) must have MINTER_ROLE on token contract + * - Use role management operations to grant MINTER_ROLE to Safe or pool + * + * @example + * ```typescript + * // Mint 1000 tokens (18 decimals) to receiver + * const mintParams: MintParams = { + * receiver: "0x1234567890123456789012345678901234567890", + * amount: "1000000000000000000000" // 1000 * 10^18 + * }; + * + * mintParamsSchema.parse(mintParams); // Valid + * ``` + * + * @example + * ```typescript + * // Mint 100 USDC (6 decimals) to receiver + * const usdcMint: MintParams = { + * receiver: "0xReceiver", + * amount: "100000000" // 100 * 10^6 + * }; + * + * mintParamsSchema.parse(usdcMint); // Valid + * ``` + * + * @example + * ```typescript + * // Validation error for invalid address + * try { + * const invalid = { + * receiver: "0xInvalidAddress", + * amount: "1000" + * }; + * mintParamsSchema.parse(invalid); + * } catch (error) { + * console.error(error.message); // "Invalid Ethereum address format for receiver" + * } + * ``` + * + * @see {@link MintParams} for inferred TypeScript type + * @see {@link createTokenMintGenerator} for usage in generator + * + * @public + */ +export const mintParamsSchema = z.object({ + /** Receiver address for minted tokens - validated format */ + receiver: z.string().refine((address: string): boolean => ethers.isAddress(address), { + message: 'Invalid Ethereum address format for receiver', + }), + + /** Token amount in wei as string to avoid precision issues */ + amount: z.string(), +}); + +/** + * Zod schema for role type selection. + * + * Defines which role(s) to grant or revoke on a BurnMintERC20 token contract. + * + * @remarks + * Role Types: + * - **mint**: MINTER_ROLE only (allows minting tokens) + * - **burn**: BURNER_ROLE only (allows burning tokens) + * - **both**: Both MINTER_ROLE and BURNER_ROLE (most common for pools) + * + * TokenPool Requirements: + * - BurnMintTokenPool requires BOTH mint and burn roles to function + * - Grant both roles to pool after deployment for cross-chain transfers + * + * @example + * ```typescript + * const roleType: RoleType = "both"; // Grant both roles + * roleTypeSchema.parse(roleType); // Valid + * ``` + * + * @see {@link roleManagementParamsSchema} for usage in role management + * + * @public + */ +export const roleTypeSchema = z.enum(['mint', 'burn', 'both']); + +/** + * Zod schema for role action selection. + * + * Defines whether to grant or revoke a role. + * + * @remarks + * Action Types: + * - **grant**: Add role to address (grantRole or grantMintAndBurn) + * - **revoke**: Remove role from address (revokeRole) + * + * Security Considerations: + * - Granting roles gives permanent permissions until revoked + * - Revoking roles immediately removes permissions + * - Ensure Safe has ADMIN_ROLE before granting/revoking roles + * + * @example + * ```typescript + * const action: ActionType = "grant"; + * actionTypeSchema.parse(action); // Valid + * ``` + * + * @see {@link roleManagementParamsSchema} for usage in role management + * + * @public + */ +export const actionTypeSchema = z.enum(['grant', 'revoke']); + +/** + * Zod schema for role management parameters. + * + * Defines the parameters for granting or revoking mint and burn roles on a + * BurnMintERC20 token contract. Used to authorize TokenPools or other addresses + * to mint and burn tokens. + * + * @remarks + * Validation Rules: + * - **pool**: Valid Ethereum address of the pool or address receiving roles + * - **roleType**: Type of role(s) to manage (mint, burn, or both) - defaults to 'both' + * - **action**: Whether to grant or revoke the role(s) - defaults to 'grant' + * + * Default Values: + * - roleType defaults to 'both' (most common for pools) + * - action defaults to 'grant' (most common operation) + * + * Typical Workflow: + * 1. Deploy token and pool via TokenPoolFactory + * 2. Grant both mint and burn roles to pool using this schema + * 3. Pool can now mint/burn tokens during cross-chain transfers + * + * @example + * ```typescript + * // Grant both roles to pool (typical after deployment) + * const grantBoth: RoleManagementParams = { + * pool: "0x1234567890123456789012345678901234567890", + * roleType: "both", // Optional, defaults to 'both' + * action: "grant" // Optional, defaults to 'grant' + * }; + * + * roleManagementParamsSchema.parse(grantBoth); // Valid + * ``` + * + * @example + * ```typescript + * // Grant only mint role + * const grantMintOnly: RoleManagementParams = { + * pool: "0xPoolAddress", + * roleType: "mint", + * action: "grant" + * }; + * + * roleManagementParamsSchema.parse(grantMintOnly); // Valid + * ``` + * + * @example + * ```typescript + * // Revoke burn role + * const revokeBurn: RoleManagementParams = { + * pool: "0xPoolAddress", + * roleType: "burn", + * action: "revoke" + * }; + * + * roleManagementParamsSchema.parse(revokeBurn); // Valid + * ``` + * + * @example + * ```typescript + * // Minimal input (uses defaults: roleType='both', action='grant') + * const minimal: RoleManagementParams = { + * pool: "0xPoolAddress" + * // roleType defaults to 'both' + * // action defaults to 'grant' + * }; + * + * const parsed = roleManagementParamsSchema.parse(minimal); + * console.log(parsed.roleType); // 'both' + * console.log(parsed.action); // 'grant' + * ``` + * + * @see {@link RoleManagementParams} for inferred TypeScript type + * @see {@link createRoleManagementGenerator} for usage in generator + * + * @public + */ +export const roleManagementParamsSchema = z.object({ + /** Pool or address receiving role grant/revoke - validated format */ + pool: z.string().refine((address: string): boolean => ethers.isAddress(address), { + message: 'Invalid Ethereum address format for pool', + }), + + /** Type of role(s) to manage - defaults to 'both' (mint and burn) */ + roleType: roleTypeSchema.optional().default('both'), + + /** Action to perform - defaults to 'grant' */ + action: actionTypeSchema.optional().default('grant'), +}); + +/** + * TypeScript type for validated mint parameters. + * + * @example + * ```typescript + * function mintTokens(params: MintParams) { + * console.log(`Minting ${params.amount} tokens to ${params.receiver}`); + * } + * + * const validated = mintParamsSchema.parse(userInput); + * mintTokens(validated); + * ``` + * + * @public + */ +export type MintParams = z.infer; + +/** + * TypeScript type for role type enum values. + * @public + */ +export type RoleType = z.infer; + +/** + * TypeScript type for action type enum values. + * @public + */ +export type ActionType = z.infer; + +/** + * TypeScript type for validated role management parameters. + * + * @example + * ```typescript + * function manageRoles(params: RoleManagementParams) { + * console.log(`${params.action} ${params.roleType} role(s) for ${params.pool}`); + * } + * + * const validated = roleManagementParamsSchema.parse(userInput); + * manageRoles(validated); + * ``` + * + * @public + */ +export type RoleManagementParams = z.infer; + +/** + * Safe Transaction Builder metadata for mint operations. + * + * Extends SafeMetadata with token address information required for formatting + * mint transactions as Safe Transaction Builder JSON. + * + * @remarks + * This metadata is used by the mint formatter to generate Safe Transaction Builder + * JSON files that can be imported into the Safe UI for multisig token minting. + * + * @example + * ```typescript + * const metadata: SafeMintMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * ownerAddress: "0x0000000000000000000000000000000000000000", + * tokenAddress: "0x779877A7B0D9E8603169DdbD7836e478b4624789" // Token to mint + * }; + * + * const safeJson = await formatter.formatAsSafeJson(mintTx, metadata); + * ``` + * + * @see {@link SafeMetadata} for base metadata fields + * @see {@link MintFormatter} for usage in formatting + * + * @public + */ +export interface SafeMintMetadata extends SafeMetadata { + /** Address of the token contract to mint from */ + tokenAddress: string; +} + +/** + * Safe Transaction Builder metadata for role management operations. + * + * Extends SafeMetadata with token address information required for formatting + * role management transactions as Safe Transaction Builder JSON. + * + * @remarks + * This metadata is used by the role management formatter to generate Safe + * Transaction Builder JSON files for granting/revoking mint and burn roles. + * + * @example + * ```typescript + * const metadata: SafeRoleManagementMetadata = { + * chainId: "84532", // Base Sepolia + * safeAddress: "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * ownerAddress: "0x0000000000000000000000000000000000000000", + * tokenAddress: "0x779877A7B0D9E8603169DdbD7836e478b4624789" // Token contract + * }; + * + * const safeJson = await formatter.formatAsSafeJson(roleManagementTx, metadata); + * ``` + * + * @see {@link SafeMetadata} for base metadata fields + * @see {@link RoleManagementFormatter} for usage in formatting + * + * @public + */ +export interface SafeRoleManagementMetadata extends SafeMetadata { + /** Address of the token contract for role management */ + tokenAddress: string; +} diff --git a/src/types/typeGuards.ts b/src/types/typeGuards.ts new file mode 100644 index 0000000..9ba2f8e --- /dev/null +++ b/src/types/typeGuards.ts @@ -0,0 +1,319 @@ +/** + * @fileoverview Type guards for runtime type checking and safe type narrowing. + * + * This module provides TypeScript type predicates (type guards) for validating unknown + * values at runtime. These guards enable safe type narrowing without resorting to + * unsafe type assertions (`as` casts). + * + * Type guards complement Zod schema validation by providing lightweight runtime checks + * before full validation, improving error messages and developer experience. + * + * @remarks + * Type Guard Pattern: + * - Type guards return `value is T` to enable TypeScript type narrowing + * - They perform basic structural validation, not exhaustive validation + * - Use Zod schemas for full validation after initial type guard check + * + * @module types/typeGuards + */ + +import { TokenDeploymentParams } from './tokenDeployment'; +import { PoolDeploymentParams } from './poolDeployment'; +import { MintParams, RoleManagementParams } from './tokenMint'; +import { AllowListUpdatesInput } from './allowList'; +import { SetChainRateLimiterConfigInput } from './rateLimiter'; + +/** + * Type guard for plain JavaScript objects. + * + * Checks if a value is a plain object (not null, not array, not function). + * This is a foundational guard used by other type predicates. + * + * @param value - Value to check + * @returns True if value is a plain object + * + * @remarks + * Excludes: + * - `null` (typeof null === 'object' in JavaScript) + * - Arrays (Array.isArray returns true) + * - Functions (not checked, but unlikely) + * + * @example + * ```typescript + * const value: unknown = getUserInput(); + * + * if (isObject(value)) { + * // TypeScript knows value is Record + * console.log(value.someProperty); + * } + * ``` + * + * @example + * ```typescript + * isObject({ foo: 'bar' }); // true + * isObject(null); // false + * isObject([1, 2, 3]); // false + * isObject('string'); // false + * ``` + * + * @public + */ +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Safely parses JSON with type validation using a type guard. + * + * Combines JSON parsing with runtime type checking to ensure the parsed + * value matches the expected type before returning it. + * + * @typeParam T - Expected type of parsed JSON + * @param json - JSON string to parse + * @param validator - Type guard function to validate parsed value + * @returns Validated parsed value of type T + * @throws {SyntaxError} If JSON is malformed + * @throws {Error} If parsed value fails type validation + * + * @remarks + * This function is useful when: + * - Reading JSON from files or user input + * - Need to validate structure before full Zod validation + * - Want better error messages for structural issues + * + * @example + * ```typescript + * // Safe JSON parsing with type guard + * const json = '{"name": "MyToken", "symbol": "MTK", "decimals": 18}'; + * const params = parseJSON(json, isTokenDeploymentParams); + * // TypeScript knows params is TokenDeploymentParams + * console.log(params.name); // "MyToken" + * ``` + * + * @example + * ```typescript + * // Error handling + * try { + * const json = '{"invalid": "structure"}'; + * const params = parseJSON(json, isTokenDeploymentParams); + * } catch (error) { + * console.error('Invalid JSON structure'); // Caught here + * } + * ``` + * + * @public + */ +export function parseJSON(json: string, validator: (value: unknown) => value is T): T { + const parsed: unknown = JSON.parse(json); + if (!validator(parsed)) { + throw new Error('Invalid JSON structure'); + } + return parsed; +} + +/** + * Type predicate for TokenDeploymentParams. + * + * Performs basic structural validation of token deployment parameters. + * Checks for presence of required fields without full validation. + * + * @param value - Value to check + * @returns True if value has TokenDeploymentParams structure + * + * @remarks + * This is a lightweight check for basic structure. Full validation including: + * - Address format validation + * - Decimal range checking + * - Remote pool configuration validation + * + * Should be done using `tokenDeploymentParamsSchema.parse()` from Zod. + * + * @example + * ```typescript + * const input: unknown = JSON.parse(fileContents); + * + * if (isTokenDeploymentParams(input)) { + * // TypeScript knows input is TokenDeploymentParams + * // Now do full validation with Zod + * const validated = tokenDeploymentParamsSchema.parse(input); + * } + * ``` + * + * @see {@link tokenDeploymentParamsSchema} for full validation + * + * @public + */ +export function isTokenDeploymentParams(value: unknown): value is TokenDeploymentParams { + if (!isObject(value)) return false; + return ( + typeof value.name === 'string' && + typeof value.symbol === 'string' && + typeof value.decimals === 'number' + ); +} + +/** + * Type predicate for PoolDeploymentParams. + * + * Performs basic structural validation of pool deployment parameters. + * + * @param value - Value to check + * @returns True if value has PoolDeploymentParams structure + * + * @remarks + * Checks for presence of `token` and `poolType` fields. Full validation + * should be done using `poolDeploymentParamsSchema.parse()`. + * + * @example + * ```typescript + * const input: unknown = JSON.parse(fileContents); + * + * if (isPoolDeploymentParams(input)) { + * // TypeScript knows input is PoolDeploymentParams + * const validated = poolDeploymentParamsSchema.parse(input); + * } + * ``` + * + * @see {@link poolDeploymentParamsSchema} for full validation + * + * @public + */ +export function isPoolDeploymentParams(value: unknown): value is PoolDeploymentParams { + if (!isObject(value)) return false; + return typeof value.token === 'string' && typeof value.poolType === 'string'; +} + +/** + * Type predicate for MintParams. + * + * Performs basic structural validation of token mint parameters. + * + * @param value - Value to check + * @returns True if value has MintParams structure + * + * @remarks + * Checks for presence of `receiver` and `amount` fields. Full validation + * including address format should be done using `mintParamsSchema.parse()`. + * + * @example + * ```typescript + * const input: unknown = JSON.parse(fileContents); + * + * if (isMintParams(input)) { + * // TypeScript knows input is MintParams + * const validated = mintParamsSchema.parse(input); + * } + * ``` + * + * @see {@link mintParamsSchema} for full validation + * + * @public + */ +export function isMintParams(value: unknown): value is MintParams { + if (!isObject(value)) return false; + return typeof value.receiver === 'string' && typeof value.amount === 'string'; +} + +/** + * Type predicate for AllowListUpdatesInput. + * + * Performs basic structural validation of allow list update parameters. + * + * @param value - Value to check + * @returns True if value has AllowListUpdatesInput structure + * + * @remarks + * Checks for presence of optional `removes` and `adds` arrays. Full validation + * including address format checking should be done using `allowListUpdatesSchema.parse()`. + * + * @example + * ```typescript + * const input: unknown = JSON.parse(fileContents); + * + * if (isAllowListUpdatesInput(input)) { + * // TypeScript knows input is AllowListUpdatesInput + * const validated = allowListUpdatesSchema.parse(input); + * } + * ``` + * + * @see {@link allowListUpdatesSchema} for full validation + * + * @public + */ +export function isAllowListUpdatesInput(value: unknown): value is AllowListUpdatesInput { + if (!isObject(value)) return false; + const removes = value.removes; + const adds = value.adds; + return ( + (Array.isArray(removes) || removes === undefined) && (Array.isArray(adds) || adds === undefined) + ); +} + +/** + * Type predicate for SetChainRateLimiterConfigInput. + * + * Performs basic structural validation of rate limiter configuration parameters. + * + * @param value - Value to check + * @returns True if value has SetChainRateLimiterConfigInput structure + * + * @remarks + * Checks for presence of `remoteChainSelector`, `outboundConfig`, and `inboundConfig`. + * Full validation should be done using `setChainRateLimiterConfigSchema.parse()`. + * + * @example + * ```typescript + * const input: unknown = JSON.parse(fileContents); + * + * if (isSetChainRateLimiterConfigInput(input)) { + * // TypeScript knows input is SetChainRateLimiterConfigInput + * const validated = setChainRateLimiterConfigSchema.parse(input); + * } + * ``` + * + * @see {@link setChainRateLimiterConfigSchema} for full validation + * + * @public + */ +export function isSetChainRateLimiterConfigInput( + value: unknown, +): value is SetChainRateLimiterConfigInput { + if (!isObject(value)) return false; + return ( + typeof value.remoteChainSelector === 'string' && + isObject(value.outboundConfig) && + isObject(value.inboundConfig) + ); +} + +/** + * Type predicate for RoleManagementParams. + * + * Performs basic structural validation of role management parameters. + * + * @param value - Value to check + * @returns True if value has RoleManagementParams structure + * + * @remarks + * Checks for presence of `pool` and `roleType` fields. Full validation + * including address format and enum values should be done using + * `roleManagementParamsSchema.parse()`. + * + * @example + * ```typescript + * const input: unknown = JSON.parse(fileContents); + * + * if (isRoleManagementParams(input)) { + * // TypeScript knows input is RoleManagementParams + * const validated = roleManagementParamsSchema.parse(input); + * } + * ``` + * + * @see {@link roleManagementParamsSchema} for full validation + * + * @public + */ +export function isRoleManagementParams(value: unknown): value is RoleManagementParams { + if (!isObject(value)) return false; + return typeof value.pool === 'string' && typeof value.roleType === 'string'; +} diff --git a/src/utils/addressComputer.ts b/src/utils/addressComputer.ts index 5ca9e38..a393246 100644 --- a/src/utils/addressComputer.ts +++ b/src/utils/addressComputer.ts @@ -1,12 +1,104 @@ +/** + * @fileoverview CREATE2 deterministic address computation for factory deployments. + * + * This module implements CREATE2 address computation matching TokenPoolFactory's + * salt modification behavior. Computes deterministic addresses for contracts + * deployed via CREATE2 opcode with sender-specific salt hashing. + * + * Key Features: + * - CREATE2 deterministic address computation + * - TokenPoolFactory salt modification: keccak256(abi.encodePacked(salt, msg.sender)) + * - Address validation with ethers.js + * - Structured logging of computation details + * - Dependency injection via factory pattern + * + * Salt Modification Behavior: + * TokenPoolFactory modifies the salt before CREATE2 deployment by hashing it + * with msg.sender. This allows different deployers to use the same salt while + * getting different deterministic addresses. + * + * @example + * ```typescript + * import { createAddressComputer, computeModifiedSalt } from './utils/addressComputer'; + * + * // Compute modified salt + * const modifiedSalt = computeModifiedSalt( + * '0x0000000000000000000000000000000000000000000000000000000123456789', + * '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e' + * ); + * + * // Create address computer with logger + * const addressComputer = createAddressComputer(logger); + * const predictedAddress = addressComputer.computeCreate2Address( + * factoryAddress, + * bytecode, + * originalSalt, + * senderAddress + * ); + * ``` + * + * @module utils/addressComputer + * @see {@link https://eips.ethereum.org/EIPS/eip-1014} EIP-1014: CREATE2 Opcode + */ + import { ethers } from 'ethers'; -import logger from './logger'; +import { ILogger, IAddressComputer } from '../interfaces'; /** - * Computes the modified salt by hashing the original salt with the sender's address - * Matches the TokenPoolFactory's salt modification: keccak256(abi.encodePacked(salt, msg.sender)) - * @param salt - The original salt - * @param sender - The address of the sender - * @returns The modified salt as bytes32 + * Computes modified salt by hashing original salt with sender address. + * + * Matches TokenPoolFactory's salt modification behavior: + * `keccak256(abi.encodePacked(salt, msg.sender))`. This ensures different + * senders using the same salt get different deterministic addresses. + * + * @param salt - Original CREATE2 salt (32 bytes, hex string with 0x prefix) + * @param sender - Ethereum address of the transaction sender + * @returns Modified salt as bytes32 hex string + * @throws {Error} If sender address is invalid + * + * @remarks + * Salt Modification Formula: + * ``` + * modifiedSalt = keccak256(solidityPacked(['bytes32', 'address'], [salt, sender])) + * ``` + * + * This matches Solidity's: + * ```solidity + * bytes32 modifiedSalt = keccak256(abi.encodePacked(salt, msg.sender)); + * ``` + * + * Why Modify Salt? + * - Allows multiple deployers to use the same salt safely + * - Each sender gets a unique deterministic address + * - Prevents address collisions across different deployers + * + * Input Validation: + * - Sender must be valid Ethereum address (20 bytes) + * - Salt should be 32 bytes (66 chars: 0x + 64 hex) + * - Validated via ethers.isAddress() + * + * @example + * ```typescript + * const salt = '0x0000000000000000000000000000000000000000000000000000000123456789'; + * const sender = '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e'; + * + * const modifiedSalt = computeModifiedSalt(salt, sender); + * // Returns: keccak256 hash of packed salt and sender + * ``` + * + * @example + * ```typescript + * // Different senders, same salt -> different modified salts + * const salt = '0x1234...'; + * const sender1 = '0xaaaa...'; + * const sender2 = '0xbbbb...'; + * + * const modified1 = computeModifiedSalt(salt, sender1); + * const modified2 = computeModifiedSalt(salt, sender2); + * // modified1 !== modified2 + * ``` + * + * @public */ export function computeModifiedSalt(salt: string, sender: string): string { if (!ethers.isAddress(sender)) { @@ -16,46 +108,141 @@ export function computeModifiedSalt(salt: string, sender: string): string { } /** - * Computes the deterministic address for a contract deployed using CREATE2 - * Uses ethers.getCreate2Address - * @param deployer - The address of the contract deploying the new contract (TokenPoolFactory) - * @param bytecode - The deployment bytecode including constructor args - * @param salt - The original salt (will be modified with sender's address) - * @param sender - The address of the sender who will deploy the contract - * @returns The computed contract address + * Creates an address computer instance with logging capabilities. + * + * Factory function that creates an IAddressComputer implementation for computing + * CREATE2 deterministic addresses. Uses dependency injection pattern for logger. + * + * @param logger - Logger instance for structured logging of address computations + * @returns Address computer implementing IAddressComputer interface + * + * @remarks + * Factory Pattern Benefits: + * - Dependency injection for logger + * - Testable via mock logger injection + * - Encapsulates CREATE2 computation logic + * - Single responsibility (address computation only) + * + * CREATE2 Address Computation: + * ``` + * address = keccak256(0xff ++ deployer ++ modifiedSalt ++ keccak256(initCode))[12:] + * ``` + * + * Where: + * - `deployer`: Factory contract address + * - `modifiedSalt`: keccak256(abi.encodePacked(salt, sender)) + * - `initCode`: Contract bytecode (constructor + args) + * + * Logged Information: + * - Deployer address (factory) + * - Original salt (user-provided) + * - Modified salt (after sender hash) + * - Sender address + * - Init code hash (bytecode hash) + * - Predicted address (final result) + * + * @example + * ```typescript + * import { createLogger } from './utils/logger'; + * import { createAddressComputer } from './utils/addressComputer'; + * + * const logger = createLogger(); + * const addressComputer = createAddressComputer(logger); + * + * // Compute token address + * const tokenAddress = addressComputer.computeCreate2Address( + * '0x779877A7B0D9E8603169DdbD7836e478b4624789', // factory + * tokenBytecode, + * '0x0000000000000000000000000000000000000000000000000000000123456789', + * '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e' // sender + * ); + * ``` + * + * @example + * ```typescript + * // Dependency injection in generator + * const generator = createTokenDeploymentGenerator( + * logger, + * interfaceProvider, + * createAddressComputer(logger) // Inject address computer + * ); + * ``` + * + * @see {@link IAddressComputer} for interface definition + * @public */ -export function computeCreate2Address( - deployer: string, - bytecode: string, - salt: string, - sender: string, -): string { - // Validate inputs - if (!ethers.isAddress(deployer)) { - throw new Error('Invalid deployer address'); - } +export function createAddressComputer(logger: ILogger): IAddressComputer { + return { + /** + * Computes CREATE2 deterministic address for factory-deployed contract. + * + * Implements CREATE2 address computation matching TokenPoolFactory behavior, + * including salt modification. Validates all inputs and logs computation details. + * + * @param deployer - Factory contract address (CREATE2 deployer) + * @param bytecode - Contract bytecode (init code including constructor) + * @param salt - Original CREATE2 salt (32 bytes) + * @param sender - Transaction sender address (for salt modification) + * @returns Deterministic contract address + * @throws {Error} If deployer or sender address is invalid + * + * @remarks + * Computation Steps: + * 1. Validate deployer and sender addresses + * 2. Compute modified salt: keccak256(abi.encodePacked(salt, sender)) + * 3. Compute init code hash: keccak256(bytecode) + * 4. Compute CREATE2 address: ethers.getCreate2Address(deployer, modifiedSalt, initCodeHash) + * 5. Log computation details + * + * The predicted address can be used to: + * - Reference contracts before deployment + * - Configure cross-chain settings pre-deployment + * - Verify deployment success + * + * @example + * ```typescript + * const predictedAddress = addressComputer.computeCreate2Address( + * '0x779877A7B0D9E8603169DdbD7836e478b4624789', // TokenPoolFactory + * BYTECODES.FactoryBurnMintERC20, + * '0x0000000000000000000000000000000000000000000000000000000123456789', + * '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e' // Safe address + * ); + * ``` + */ + computeCreate2Address( + deployer: string, + bytecode: string, + salt: string, + sender: string, + ): string { + // Validate inputs + if (!ethers.isAddress(deployer)) { + throw new Error('Invalid deployer address'); + } - if (!ethers.isAddress(sender)) { - throw new Error('Invalid sender address'); - } + if (!ethers.isAddress(sender)) { + throw new Error('Invalid sender address'); + } - // Modify the salt as done in the TokenPoolFactory - const modifiedSalt = computeModifiedSalt(salt, sender); + // Modify the salt as done in the TokenPoolFactory + const modifiedSalt = computeModifiedSalt(salt, sender); - // Compute the init code hash - const initCodeHash = ethers.keccak256(bytecode); + // Compute the init code hash + const initCodeHash = ethers.keccak256(bytecode); - // Use ethers.getCreate2Address to compute the deterministic address - const predictedAddress = ethers.getCreate2Address(deployer, modifiedSalt, initCodeHash); + // Use ethers.getCreate2Address to compute the deterministic address + const predictedAddress = ethers.getCreate2Address(deployer, modifiedSalt, initCodeHash); - logger.info('Computed CREATE2 address', { - deployer, - originalSalt: salt, - modifiedSalt, - sender, - initCodeHash, - predictedAddress, - }); + logger.info('Computed CREATE2 address', { + deployer, + originalSalt: salt, + modifiedSalt, + sender, + initCodeHash, + predictedAddress, + }); - return predictedAddress; + return predictedAddress; + }, + }; } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index c815ed5..b00a2b2 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,25 +1,103 @@ +/** + * @fileoverview Winston-based logger implementation for structured logging. + * + * Provides a production-ready logger implementation using Winston with file and console + * transports. Implements the ILogger interface for dependency injection throughout the + * application. + * + * @module utils/logger + */ + import * as winston from 'winston'; import { Logger, format, transports } from 'winston'; +import { LOGGING_CONFIG } from '../config'; +import { ILogger } from '../interfaces'; + +/** + * Creates a Winston logger instance implementing ILogger interface. + * + * Factory function that configures a Winston logger with file transports for error + * and combined logs, plus optional console output for development. Uses JSON format + * for structured logging with timestamps. + * + * @returns Logger instance implementing ILogger interface + * + * @remarks + * Logger Configuration: + * - **Level**: Configurable via LOGGING_CONFIG (default: 'info') + * - **Format**: JSON with timestamps for structured logging + * - **Transports**: + * - File transport for errors (error.log) + * - File transport for all logs (combined.log) + * - Console transport (development only, colorized) + * - **Metadata**: Includes service name in all log entries + * + * The logger is used throughout the application for: + * - Transaction generation progress + * - Validation results + * - Error reporting + * - Debug information + * + * @example + * ```typescript + * const logger = createLogger(); + * + * // Info logging with metadata + * logger.info('Token deployment transaction generated', { + * tokenAddress: '0x123...', + * poolAddress: '0xabc...', + * factoryAddress: '0xdef...' + * }); + * + * // Error logging + * logger.error('Failed to validate input', { + * error: error.message, + * inputJson, + * stack: error.stack + * }); + * ``` + * + * @example + * ```typescript + * // Dependency injection (recommended) + * const generator = createTokenDeploymentGenerator( + * createLogger(), // Fresh logger instance + * interfaceProvider, + * addressComputer + * ); + * ``` + * + * @see {@link ILogger} for interface definition + * @see {@link LOGGING_CONFIG} for configuration options + * + * @public + */ +export function createLogger(): ILogger { + const logger = winston.createLogger({ + level: LOGGING_CONFIG.level, + format: format.combine(format.timestamp(), format.json()), + defaultMeta: { service: LOGGING_CONFIG.serviceName } as const, + transports: [ + new transports.File({ filename: LOGGING_CONFIG.files.error, level: 'error' }), + new transports.File({ filename: LOGGING_CONFIG.files.combined }), + ], + }) satisfies Logger; -// Create the logger with explicit types -const logger = winston.createLogger({ - level: 'info', - format: format.combine(format.timestamp(), format.json()), - defaultMeta: { service: 'token-pool-calldata' } as const, - transports: [ - new transports.File({ filename: 'error.log', level: 'error' }), - new transports.File({ filename: 'combined.log' }), - ], -}) satisfies Logger; + // If console logging is enabled (non-production), log to the console as well + if (LOGGING_CONFIG.enableConsole) { + logger.add( + new transports.Console({ + format: format.combine(format.colorize(), format.simple()), + }), + ); + } -// If we're not in production, log to the console as well -if (process.env.NODE_ENV !== 'production') { - logger.add( - new transports.Console({ - format: format.combine(format.colorize(), format.simple()), - }), - ); + return logger; } -// Export the logger instance -export default logger; +/** + * Default logger instance for utility functions and error handlers + * For application code, use dependency injection via createLogger() + */ +const defaultLogger = createLogger(); +export default defaultLogger; diff --git a/src/utils/poolTypeConverter.ts b/src/utils/poolTypeConverter.ts index 30ef8c0..6967316 100644 --- a/src/utils/poolTypeConverter.ts +++ b/src/utils/poolTypeConverter.ts @@ -1,19 +1,134 @@ +/** + * @fileoverview Pool type conversion utilities for TokenPool contracts. + * + * This module provides bidirectional conversion between TypeScript PoolType enum + * values (string literals) and the numeric values used in Solidity contracts. + * + * TokenPool contracts use numeric enum values in Solidity: + * - 0 = BurnMintTokenPool (tokens burned on source chain, minted on destination) + * - 1 = LockReleaseTokenPool (tokens locked on source chain, released on destination) + * + * TypeScript uses string literal types for better type safety and readability: + * - "BurnMintTokenPool" + * - "LockReleaseTokenPool" + * + * These converters bridge the gap between TypeScript types and contract values. + * + * @module utils/poolTypeConverter + */ + import { PoolType } from '../types/poolDeployment'; /** - * Converts a PoolType enum value to its corresponding contract numeric value + * Converts PoolType string to Solidity contract numeric value. + * + * Maps TypeScript PoolType enum to the numeric value expected by TokenPoolFactory + * contracts. Used when encoding function calls for pool deployment. + * * @param poolType - The pool type enum value - * @returns The numeric value used in the contract (0 for BurnMintTokenPool, 1 for LockReleaseTokenPool) + * @returns The numeric value used in the contract (0 or 1) + * + * @remarks + * Mapping: + * - "BurnMintTokenPool" → 0 + * - "LockReleaseTokenPool" → 1 + * + * Pool Type Semantics: + * - **BurnMintTokenPool (0)**: For mintable/burnable tokens. Burns tokens when + * sending cross-chain, mints tokens when receiving. Requires mint/burn roles. + * - **LockReleaseTokenPool (1)**: For non-mintable tokens. Locks tokens in the + * pool when sending, releases from pool when receiving. Requires token balance. + * + * @example + * ```typescript + * import { poolTypeToNumber } from './poolTypeConverter'; + * + * // Convert for BurnMintTokenPool + * const burnMintValue = poolTypeToNumber('BurnMintTokenPool'); + * console.log(burnMintValue); // 0 + * + * // Convert for LockReleaseTokenPool + * const lockReleaseValue = poolTypeToNumber('LockReleaseTokenPool'); + * console.log(lockReleaseValue); // 1 + * ``` + * + * @example + * ```typescript + * // Using in contract call encoding + * const poolType = 'BurnMintTokenPool'; + * const encodedData = factoryInterface.encodeFunctionData('deployPool', [ + * tokenAddress, + * poolTypeToNumber(poolType), // Converts to 0 + * salt + * ]); + * ``` + * + * @see {@link numberToPoolType} for reverse conversion + * @public */ export function poolTypeToNumber(poolType: PoolType): number { return poolType === 'BurnMintTokenPool' ? 0 : 1; } /** - * Converts a contract numeric value to its corresponding PoolType enum value + * Converts Solidity contract numeric value to PoolType string. + * + * Maps numeric values from TokenPoolFactory contracts back to TypeScript PoolType + * enum. Used when decoding contract responses or validating pool configurations. + * * @param value - The numeric value from the contract (0 or 1) * @returns The corresponding PoolType enum value - * @throws Error if the value is not 0 or 1 + * @throws {Error} If value is not 0 or 1 + * + * @remarks + * Mapping: + * - 0 → "BurnMintTokenPool" + * - 1 → "LockReleaseTokenPool" + * - Any other value → Error + * + * Validation: + * - Only accepts 0 or 1 + * - Throws descriptive error for invalid values + * - Ensures type safety when reading from contracts + * + * @example + * ```typescript + * import { numberToPoolType } from './poolTypeConverter'; + * + * // Convert from contract value 0 + * const poolType0 = numberToPoolType(0); + * console.log(poolType0); // "BurnMintTokenPool" + * + * // Convert from contract value 1 + * const poolType1 = numberToPoolType(1); + * console.log(poolType1); // "LockReleaseTokenPool" + * ``` + * + * @example + * ```typescript + * // Error case - invalid value + * try { + * const poolType = numberToPoolType(2); + * } catch (error) { + * // Error: Invalid pool type value: 2. Expected 0 or 1. + * } + * ``` + * + * @example + * ```typescript + * // Using with contract response + * const poolTypeValue = await poolContract.getPoolType(); + * const poolType = numberToPoolType(poolTypeValue); + * + * if (poolType === 'BurnMintTokenPool') { + * console.log('Pool requires mint/burn roles'); + * } else { + * console.log('Pool requires token balance'); + * } + * ``` + * + * @see {@link poolTypeToNumber} for reverse conversion + * @public */ export function numberToPoolType(value: number): PoolType { if (value === 0) return 'BurnMintTokenPool'; diff --git a/src/utils/safeJsonBuilder.ts b/src/utils/safeJsonBuilder.ts new file mode 100644 index 0000000..9220112 --- /dev/null +++ b/src/utils/safeJsonBuilder.ts @@ -0,0 +1,404 @@ +/** + * @fileoverview Safe Transaction Builder JSON construction utilities. + * + * This module provides builder functions for creating Safe Transaction Builder JSON + * files that can be imported into the Safe Transaction Builder UI. These utilities + * standardize Safe JSON creation across all generator types. + * + * Safe Transaction Builder JSON is a structured format that includes: + * - Transaction metadata (chain ID, Safe address, owner, version) + * - Transaction data (to, value, data, operation) + * - Contract method signatures with input types + * - Human-readable names and descriptions + * + * The builders extract method fragments from ethers.js Interface objects to ensure + * accurate type information for the Safe UI. + * + * @module utils/safeJsonBuilder + * @see {@link https://help.safe.global/en/articles/40841-transaction-builder} Safe Transaction Builder docs + */ + +import { ethers } from 'ethers'; +import { + SafeTransactionDataBase, + SafeTransactionBuilderJSON, + SafeMetadata, + SAFE_TX_BUILDER_VERSION, +} from '../types/safe'; +import { DEFAULTS } from '../config'; + +/** + * Options for building Safe Transaction Builder JSON for a single transaction. + * + * @remarks + * All fields are required to construct a valid Safe JSON file. The contract interface + * and function name are used to extract accurate method signature information for + * the Safe UI. + * + * @example + * ```typescript + * const options: SafeJsonBuilderOptions = { + * transaction: { + * to: '0x1234...', + * value: '0', + * data: '0xabcd...', + * operation: SafeOperationType.Call + * }, + * metadata: { + * chainId: '84532', + * safeAddress: '0x5419...', + * ownerAddress: '0x0000...' + * }, + * name: 'Deploy BurnMintERC20 Token', + * description: 'Deploy LINK token with 18 decimals', + * contractInterface: new ethers.Interface(FactoryABI), + * functionName: 'deployToken' + * }; + * ``` + * + * @public + */ +export interface SafeJsonBuilderOptions { + /** Transaction data (to, value, data, operation) */ + transaction: SafeTransactionDataBase; + + /** Safe metadata (chain ID, Safe address, owner address) */ + metadata: SafeMetadata; + + /** Human-readable transaction name (shown in Safe UI) */ + name: string; + + /** Detailed transaction description (shown in Safe UI) */ + description: string; + + /** Contract interface for extracting method fragment */ + contractInterface: ethers.Interface; + + /** Function name to extract from the interface */ + functionName: string; +} + +/** + * Options for building Safe Transaction Builder JSON with multiple transactions. + * + * @remarks + * Used for batched operations that require multiple transactions in a single Safe + * JSON file (e.g., granting both mint and burn roles, deploying token and pool). + * + * The number of transactions must match the number of function names. Each transaction + * is paired with its corresponding function name by array index. + * + * @example + * ```typescript + * const options: MultiSafeJsonBuilderOptions = { + * transactions: [ + * { to: '0xToken', value: '0', data: '0x1111...', operation: 0 }, + * { to: '0xToken', value: '0', data: '0x2222...', operation: 0 } + * ], + * metadata: { + * chainId: '84532', + * safeAddress: '0x5419...', + * ownerAddress: '0x0000...' + * }, + * name: 'Grant Mint and Burn Roles', + * description: 'Grant both roles to TokenPool', + * contractInterface: new ethers.Interface(BurnMintERC20ABI), + * functionNames: ['grantMintRole', 'grantBurnRole'] + * }; + * ``` + * + * @public + */ +export interface MultiSafeJsonBuilderOptions { + /** Array of transaction data (must match functionNames length) */ + transactions: SafeTransactionDataBase[]; + + /** Safe metadata (chain ID, Safe address, owner address) */ + metadata: SafeMetadata; + + /** Human-readable transaction batch name (shown in Safe UI) */ + name: string; + + /** Detailed batch description (shown in Safe UI) */ + description: string; + + /** Contract interface for extracting method fragments */ + contractInterface: ethers.Interface; + + /** Function names corresponding to each transaction (must match transactions length) */ + functionNames: string[]; +} + +/** + * Builds Safe Transaction Builder JSON for a single transaction. + * + * Constructs a complete Safe Transaction Builder JSON file from transaction data, + * metadata, and contract interface information. Extracts method signature from the + * contract interface to provide accurate type information for the Safe UI. + * + * @param options - Builder options with transaction, metadata, and interface + * @returns Complete Safe Transaction Builder JSON structure + * @throws {Error} If function name not found in contract interface + * + * @remarks + * Construction Steps: + * 1. Extract method fragment from contract interface using function name + * 2. Build transaction object with contractMethod signature + * 3. Wrap in Safe JSON structure with metadata and version info + * + * Version Information: + * - Uses DEFAULTS.SAFE_TX_VERSION for Safe JSON version + * - Uses SAFE_TX_BUILDER_VERSION for Transaction Builder UI version + * - Sets createdAt to current timestamp + * + * Method Fragment Extraction: + * - Gets function from ethers.Interface by name + * - Maps input parameters to Safe's input format (name, type, internalType) + * - Includes payable flag for the method + * + * @example + * ```typescript + * const factoryInterface = new ethers.Interface(TokenPoolFactoryABI); + * const transaction = { + * to: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * value: '0', + * data: '0x1234...', + * operation: SafeOperationType.Call + * }; + * + * const safeJson = buildSafeTransactionJson({ + * transaction, + * metadata: { + * chainId: '84532', + * safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + * ownerAddress: '0x0000000000000000000000000000000000000000' + * }, + * name: 'Deploy BurnMintERC20 Token', + * description: 'Deploy LINK token contract via TokenPoolFactory', + * contractInterface: factoryInterface, + * functionName: 'deployToken' + * }); + * + * // Output: SafeTransactionBuilderJSON with single transaction + * // Can be imported into Safe Transaction Builder UI + * ``` + * + * @example + * ```typescript + * // Error case - invalid function name + * try { + * buildSafeTransactionJson({ + * transaction: { ... }, + * metadata: { ... }, + * name: 'Test', + * description: 'Test', + * contractInterface: factoryInterface, + * functionName: 'nonExistentFunction' + * }); + * } catch (error) { + * // Error: Function nonExistentFunction not found in contract interface + * } + * ``` + * + * @see {@link buildMultiSafeTransactionJson} for multi-transaction batches + * @public + */ +export function buildSafeTransactionJson( + options: SafeJsonBuilderOptions, +): SafeTransactionBuilderJSON { + const { transaction, metadata, name, description, contractInterface, functionName } = options; + + const methodFragment = contractInterface.getFunction(functionName); + if (!methodFragment) { + throw new Error(`Function ${functionName} not found in contract interface`); + } + + return { + version: DEFAULTS.SAFE_TX_VERSION, + chainId: metadata.chainId, + createdAt: Date.now(), + meta: { + name, + description, + txBuilderVersion: SAFE_TX_BUILDER_VERSION, + createdFromSafeAddress: metadata.safeAddress, + createdFromOwnerAddress: metadata.ownerAddress, + }, + transactions: [ + { + to: transaction.to, + value: transaction.value, + data: transaction.data, + operation: transaction.operation, + contractMethod: { + inputs: methodFragment.inputs.map((input) => ({ + name: input.name, + type: input.type, + internalType: input.type, + })), + name: methodFragment.name, + payable: methodFragment.payable, + }, + contractInputsValues: null, + }, + ], + }; +} + +/** + * Builds Safe Transaction Builder JSON for multiple transactions in a batch. + * + * Constructs a complete Safe Transaction Builder JSON file containing multiple + * transactions. Each transaction is paired with its corresponding function name + * to extract accurate method signatures from the contract interface. + * + * @param options - Builder options with transaction array, metadata, and interface + * @returns Complete Safe Transaction Builder JSON structure with multiple transactions + * @throws {Error} If transaction count doesn't match function name count + * @throws {Error} If any function name not found in contract interface + * + * @remarks + * Use Cases: + * - Granting multiple roles (mint + burn) to a pool + * - Deploying token and pool in a single batch + * - Configuring multiple cross-chain connections + * - Any operation requiring atomic execution of multiple transactions + * + * Validation: + * - Validates transaction count matches function name count + * - Validates all function names exist in contract interface + * - Each transaction paired with corresponding function by index + * + * Construction Steps: + * 1. Validate array lengths match + * 2. For each transaction: + * - Extract method fragment using function name at same index + * - Build transaction object with contractMethod signature + * 3. Wrap all transactions in Safe JSON structure + * + * @example + * ```typescript + * const tokenInterface = new ethers.Interface(BurnMintERC20ABI); + * const transactions = [ + * { + * to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + * value: '0', + * data: '0x1111...', // grantMintRole calldata + * operation: SafeOperationType.Call + * }, + * { + * to: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + * value: '0', + * data: '0x2222...', // grantBurnRole calldata + * operation: SafeOperationType.Call + * } + * ]; + * + * const safeJson = buildMultiSafeTransactionJson({ + * transactions, + * metadata: { + * chainId: '84532', + * safeAddress: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + * ownerAddress: '0x0000000000000000000000000000000000000000' + * }, + * name: 'Grant Mint and Burn Roles to Pool', + * description: 'Grant both mint and burn roles to TokenPool for cross-chain transfers', + * contractInterface: tokenInterface, + * functionNames: ['grantMintRole', 'grantBurnRole'] + * }); + * + * // Output: SafeTransactionBuilderJSON with 2 transactions + * // Both transactions executed atomically when Safe owners approve + * ``` + * + * @example + * ```typescript + * // Error case - mismatched array lengths + * try { + * buildMultiSafeTransactionJson({ + * transactions: [tx1, tx2, tx3], + * metadata: { ... }, + * name: 'Test', + * description: 'Test', + * contractInterface: iface, + * functionNames: ['func1', 'func2'] // Only 2 names for 3 transactions + * }); + * } catch (error) { + * // Error: Mismatch between transactions (3) and functionNames (2) + * } + * ``` + * + * @example + * ```typescript + * // Error case - invalid function name at index 1 + * try { + * buildMultiSafeTransactionJson({ + * transactions: [tx1, tx2], + * metadata: { ... }, + * name: 'Test', + * description: 'Test', + * contractInterface: iface, + * functionNames: ['validFunc', 'invalidFunc'] + * }); + * } catch (error) { + * // Error: Function invalidFunc not found in contract interface at index 1 + * } + * ``` + * + * @see {@link buildSafeTransactionJson} for single transaction + * @public + */ +export function buildMultiSafeTransactionJson( + options: MultiSafeJsonBuilderOptions, +): SafeTransactionBuilderJSON { + const { transactions, metadata, name, description, contractInterface, functionNames } = options; + + if (transactions.length !== functionNames.length) { + throw new Error( + `Mismatch between transactions (${transactions.length}) and functionNames (${functionNames.length})`, + ); + } + + const safeTransactions = transactions.map((tx, index) => { + const functionName = functionNames[index]; + if (!functionName) { + throw new Error(`Function name not found for transaction at index ${index}`); + } + + const methodFragment = contractInterface.getFunction(functionName); + if (!methodFragment) { + throw new Error(`Function ${functionName} not found in contract interface at index ${index}`); + } + + return { + to: tx.to, + value: tx.value, + data: tx.data, + operation: tx.operation, + contractMethod: { + inputs: methodFragment.inputs.map((input) => ({ + name: input.name, + type: input.type, + internalType: input.type, + })), + name: methodFragment.name, + payable: methodFragment.payable, + }, + contractInputsValues: null, + }; + }); + + return { + version: DEFAULTS.SAFE_TX_VERSION, + chainId: metadata.chainId, + createdAt: Date.now(), + meta: { + name, + description, + txBuilderVersion: SAFE_TX_BUILDER_VERSION, + createdFromSafeAddress: metadata.safeAddress, + createdFromOwnerAddress: metadata.ownerAddress, + }, + transactions: safeTransactions, + }; +} diff --git a/src/utils/transactionFactory.ts b/src/utils/transactionFactory.ts new file mode 100644 index 0000000..4a9ee23 --- /dev/null +++ b/src/utils/transactionFactory.ts @@ -0,0 +1,349 @@ +/** + * @fileoverview Transaction factory utilities for creating Safe transaction objects. + * + * This module provides factory functions for constructing SafeTransactionDataBase + * objects with standardized defaults and consistent structure. Handles function + * encoding via ethers.js Interface and transaction creation. + * + * Transaction objects created here are used by: + * - Generator functions to produce transaction data + * - Output writers to format calldata and Safe JSON + * - Safe Transaction Builder JSON construction + * + * All transactions default to: + * - value: '0' (no ETH transfer) + * - operation: SafeOperationType.Call (standard call, not delegatecall) + * + * @module utils/transactionFactory + */ + +import { ethers } from 'ethers'; +import { SafeTransactionDataBase, SafeOperationType } from '../types/safe'; +import { DEFAULTS } from '../config'; + +/** + * Options for creating a Safe transaction data object. + * + * @remarks + * Required fields: `to` and `data` + * Optional fields: `value` (defaults to '0'), `operation` (defaults to Call) + * + * @example + * ```typescript + * const options: TransactionOptions = { + * to: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * data: '0x1234567890abcdef...', + * value: '0', + * operation: SafeOperationType.Call + * }; + * ``` + * + * @public + */ +export interface TransactionOptions { + /** Target contract address (20-byte Ethereum address) */ + to: string; + + /** Encoded function data (hex string starting with 0x) */ + data: string; + + /** Transaction value in wei (defaults to '0' for no ETH transfer) */ + value?: string; + + /** Operation type (defaults to Call for standard contract calls) */ + operation?: SafeOperationType; +} + +/** + * Creates a standardized Safe transaction data object. + * + * Factory function for constructing SafeTransactionDataBase objects with + * consistent defaults. Used as the foundational builder for all transaction + * creation in the codebase. + * + * @param options - Transaction creation options (to, data, value, operation) + * @returns Complete SafeTransactionDataBase object with defaults applied + * + * @remarks + * Default Values: + * - `value`: '0' (no ETH transfer) from DEFAULTS.TRANSACTION_VALUE + * - `operation`: SafeOperationType.Call (standard call, not delegatecall) + * + * Use Cases: + * - When you already have encoded function data + * - For custom transactions not using standard function calls + * - As a building block for higher-level factory functions + * + * @example + * ```typescript + * import { createTransaction } from './transactionFactory'; + * import { SafeOperationType } from '../types/safe'; + * + * // Create transaction with encoded data + * const transaction = createTransaction({ + * to: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * data: '0x1234567890abcdef...' + * }); + * + * console.log(transaction); + * // { + * // to: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * // value: '0', + * // data: '0x1234567890abcdef...', + * // operation: 0 // SafeOperationType.Call + * // } + * ``` + * + * @example + * ```typescript + * // Create transaction with custom value and operation + * const transaction = createTransaction({ + * to: '0x1234567890123456789012345678901234567890', + * data: '0xabcd...', + * value: '1000000000000000000', // 1 ETH in wei + * operation: SafeOperationType.DelegateCall + * }); + * ``` + * + * @example + * ```typescript + * // Common pattern in generators + * const factoryInterface = new ethers.Interface(FactoryABI); + * const encodedData = factoryInterface.encodeFunctionData('deployToken', args); + * + * const transaction = createTransaction({ + * to: factoryAddress, + * data: encodedData + * }); + * ``` + * + * @see {@link createFunctionCallTransaction} for automatic encoding + * @public + */ +export function createTransaction(options: TransactionOptions): SafeTransactionDataBase { + const { + to, + data, + value = DEFAULTS.TRANSACTION_VALUE, + operation = SafeOperationType.Call, + } = options; + + return { + to, + value, + data, + operation, + }; +} + +/** + * Encodes function call data using ethers.js contract interface. + * + * Thin wrapper around ethers.Interface.encodeFunctionData for consistent + * function encoding across the codebase. Encodes function name and arguments + * into hex-encoded calldata. + * + * @param contractInterface - The ethers.js Interface for the contract + * @param functionName - The function name to encode (e.g., 'deployToken') + * @param args - Array of arguments matching function signature + * @returns Hex-encoded function calldata (0x-prefixed) + * + * @remarks + * Encoding Process: + * 1. Looks up function in interface by name + * 2. ABI-encodes function selector (first 4 bytes of keccak256(signature)) + * 3. ABI-encodes arguments according to function signature + * 4. Concatenates selector + encoded args + * 5. Returns hex string + * + * Error Handling: + * - Throws if function name not found in interface + * - Throws if argument count/types don't match signature + * - ethers.js provides detailed error messages + * + * @example + * ```typescript + * import { ethers } from 'ethers'; + * import { encodeFunctionData } from './transactionFactory'; + * + * const factoryInterface = new ethers.Interface([ + * 'function deployToken(bytes32 salt, address owner) returns (address)' + * ]); + * + * const calldata = encodeFunctionData( + * factoryInterface, + * 'deployToken', + * [ + * '0x0000000000000000000000000000000000000000000000000000000123456789', + * '0x779877A7B0D9E8603169DdbD7836e478b4624789' + * ] + * ); + * + * console.log(calldata); + * // 0x1234abcd... (function selector + encoded args) + * ``` + * + * @example + * ```typescript + * // With complex argument types + * const tokenInterface = new ethers.Interface(BurnMintERC20ABI); + * + * const mintCalldata = encodeFunctionData( + * tokenInterface, + * 'mint', + * [ + * '0x1234567890123456789012345678901234567890', // receiver + * '1000000000000000000000' // amount (1000 tokens with 18 decimals) + * ] + * ); + * ``` + * + * @example + * ```typescript + * // Error case - wrong function name + * try { + * encodeFunctionData(factoryInterface, 'nonExistent', []); + * } catch (error) { + * // ethers.js error: no matching function + * } + * ``` + * + * @see {@link createFunctionCallTransaction} for combined encoding + transaction creation + * @public + */ +export function encodeFunctionData( + contractInterface: ethers.Interface, + functionName: string, + args: unknown[], +): string { + return contractInterface.encodeFunctionData(functionName, args); +} + +/** + * Creates a transaction with automatically encoded function call data. + * + * High-level factory function that combines function encoding and transaction + * creation into a single step. Most convenient method for creating transactions + * from function calls. + * + * @param to - Target contract address + * @param contractInterface - The ethers.js Interface for the contract + * @param functionName - The function name to call (e.g., 'deployToken') + * @param args - Array of arguments matching function signature + * @param options - Optional transaction options (value, operation) + * @returns Complete SafeTransactionDataBase object with encoded function call + * + * @remarks + * Convenience Function: + * - Combines encodeFunctionData() + createTransaction() in one call + * - Most commonly used factory function in generator code + * - Reduces boilerplate and potential errors + * + * Process: + * 1. Encode function call using interface + * 2. Create transaction with encoded data + * 3. Apply defaults (value='0', operation=Call) + * 4. Merge optional overrides (value, operation) + * + * @example + * ```typescript + * import { ethers } from 'ethers'; + * import { createFunctionCallTransaction } from './transactionFactory'; + * + * const factoryInterface = new ethers.Interface(TokenPoolFactoryABI); + * const factoryAddress = '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e'; + * + * // Create transaction for deployToken call + * const transaction = createFunctionCallTransaction( + * factoryAddress, + * factoryInterface, + * 'deployToken', + * [ + * '0x0000000000000000000000000000000000000000000000000000000123456789', // salt + * tokenParams, // TokenDeployParams struct + * poolParams // PoolDeployParams struct + * ] + * ); + * + * console.log(transaction); + * // { + * // to: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * // value: '0', + * // data: '0x1234abcd...', // encoded deployToken call + * // operation: 0 + * // } + * ``` + * + * @example + * ```typescript + * // With custom transaction options + * const tokenInterface = new ethers.Interface(BurnMintERC20ABI); + * const tokenAddress = '0x779877A7B0D9E8603169DdbD7836e478b4624789'; + * + * const mintTransaction = createFunctionCallTransaction( + * tokenAddress, + * tokenInterface, + * 'mint', + * [ + * '0x1234567890123456789012345678901234567890', // receiver + * '1000000000000000000000' // amount + * ], + * { + * // Optional overrides + * value: '0', // No ETH transfer (default) + * operation: SafeOperationType.Call // Standard call (default) + * } + * ); + * ``` + * + * @example + * ```typescript + * // Common pattern in generators + * export async function generateTokenDeployment(params: TokenDeploymentInput) { + * const factoryInterface = new ethers.Interface(TokenPoolFactoryABI); + * + * const transaction = createFunctionCallTransaction( + * params.deployer, + * factoryInterface, + * 'deployToken', + * [params.salt, tokenParams, poolParams] + * ); + * + * return { transaction, safeJson: null }; + * } + * ``` + * + * @example + * ```typescript + * // Error case - wrong arguments + * try { + * createFunctionCallTransaction( + * factoryAddress, + * factoryInterface, + * 'deployToken', + * ['wrong', 'args'] // Doesn't match function signature + * ); + * } catch (error) { + * // ethers.js error: argument type mismatch + * } + * ``` + * + * @see {@link createTransaction} for manual encoding + * @see {@link encodeFunctionData} for encoding only + * @public + */ +export function createFunctionCallTransaction( + to: string, + contractInterface: ethers.Interface, + functionName: string, + args: unknown[], + options?: Omit, +): SafeTransactionDataBase { + const data = encodeFunctionData(contractInterface, functionName, args); + + return createTransaction({ + to, + data, + ...options, + }); +} diff --git a/src/validators/ValidationError.ts b/src/validators/ValidationError.ts new file mode 100644 index 0000000..03bd634 --- /dev/null +++ b/src/validators/ValidationError.ts @@ -0,0 +1,217 @@ +/** + * @fileoverview Custom error class and result types for validation operations. + * + * This module provides a structured approach to handling validation errors throughout + * the application. ValidationError extends the standard Error class with additional + * fields for better error reporting and debugging. + * + * Also provides a Result type pattern for validation operations that prefer returning + * success/failure objects instead of throwing exceptions. + * + * @module validators/ValidationError + */ + +/** + * Custom error class for validation failures. + * + * Extends the standard Error class with structured information about validation + * failures, including the specific field that failed validation and the invalid value. + * + * @remarks + * ValidationError provides: + * - **message**: Human-readable error description + * - **field**: Optional field name that failed validation (e.g., "safe", "salt") + * - **value**: Optional invalid value that caused the failure + * - **name**: Set to "ValidationError" for error type identification + * + * This structured approach enables: + * - Better error messages for CLI users + * - Programmatic error handling by field name + * - Debugging by inspecting invalid values + * - Error logging with context + * + * @example + * ```typescript + * // Throw validation error with field and value + * throw new ValidationError( + * 'Invalid Ethereum address format', + * 'safe', + * '0xInvalidAddress' + * ); + * ``` + * + * @example + * ```typescript + * // Catch and handle validation errors + * try { + * validateAddress(userInput); + * } catch (error) { + * if (error instanceof ValidationError) { + * console.error(`Field: ${error.field}`); + * console.error(`Value: ${error.value}`); + * console.error(`Message: ${error.message}`); + * } + * } + * ``` + * + * @example + * ```typescript + * // Error without field/value (general validation error) + * throw new ValidationError('Missing required Safe JSON parameters'); + * ``` + * + * @public + */ +export class ValidationError extends Error { + /** + * Creates a new ValidationError instance. + * + * @param message - Human-readable error description + * @param field - Optional field name that failed validation + * @param value - Optional invalid value that caused the failure + */ + constructor( + message: string, + public readonly field?: string, + public readonly value?: unknown, + ) { + super(message); + this.name = 'ValidationError'; + } +} + +/** + * Result type for validation operations using the Result pattern. + * + * Discriminated union representing either a successful validation with data + * or a failed validation with an error. This pattern is an alternative to + * throwing exceptions for validation failures. + * + * @typeParam T - Type of the validated data on success + * + * @remarks + * Result Pattern Benefits: + * - Explicit error handling (compiler enforces checking) + * - No try-catch blocks needed + * - Composable validation pipelines + * - Better type inference + * + * The discriminated union uses the `success` field to distinguish between + * success and failure cases, enabling TypeScript type narrowing. + * + * @example + * ```typescript + * // Function returning validation result + * function validateToken(input: string): ValidationResult { + * if (!ethers.isAddress(input)) { + * return validationFailure('Invalid address', 'token', input); + * } + * return validationSuccess(input); + * } + * + * // Using the result + * const result = validateToken(userInput); + * if (result.success) { + * // TypeScript knows result.data is string + * console.log(`Valid token: ${result.data}`); + * } else { + * // TypeScript knows result.error is ValidationError + * console.error(`Validation failed: ${result.error.message}`); + * } + * ``` + * + * @example + * ```typescript + * // Chaining validations with Result pattern + * function validateAndProcess(input: string): ValidationResult { + * const addressResult = validateToken(input); + * if (!addressResult.success) { + * return addressResult; // Forward error + * } + * + * // Continue processing with validated data + * const processed = processAddress(addressResult.data); + * return validationSuccess(processed); + * } + * ``` + * + * @public + */ +export type ValidationResult = + | { success: true; data: T } + | { success: false; error: ValidationError }; + +/** + * Helper function to create a successful validation result. + * + * Constructs a ValidationResult representing successful validation with + * the validated data. + * + * @typeParam T - Type of the validated data + * @param data - The validated data + * @returns ValidationResult indicating success with data + * + * @example + * ```typescript + * function validatePositiveNumber(value: number): ValidationResult { + * if (value <= 0) { + * return validationFailure('Must be positive', 'value', value); + * } + * return validationSuccess(value); + * } + * + * const result = validatePositiveNumber(42); + * // result = { success: true, data: 42 } + * ``` + * + * @public + */ +export function validationSuccess(data: T): ValidationResult { + return { success: true, data }; +} + +/** + * Helper function to create a failed validation result. + * + * Constructs a ValidationResult representing validation failure with + * a ValidationError containing details about the failure. + * + * @typeParam T - Type of the data that failed validation + * @param message - Human-readable error description + * @param field - Optional field name that failed validation + * @param value - Optional invalid value that caused the failure + * @returns ValidationResult indicating failure with error + * + * @example + * ```typescript + * function validateEmail(email: string): ValidationResult { + * if (!email.includes('@')) { + * return validationFailure( + * 'Invalid email format', + * 'email', + * email + * ); + * } + * return validationSuccess(email); + * } + * + * const result = validateEmail('invalid'); + * // result = { + * // success: false, + * // error: ValidationError { + * // message: 'Invalid email format', + * // field: 'email', + * // value: 'invalid' + * // } + * // } + * ``` + * + * @public + */ +export function validationFailure( + message: string, + field?: string, + value?: unknown, +): ValidationResult { + return { success: false, error: new ValidationError(message, field, value) }; +} diff --git a/src/validators/addressValidator.ts b/src/validators/addressValidator.ts new file mode 100644 index 0000000..27bbc85 --- /dev/null +++ b/src/validators/addressValidator.ts @@ -0,0 +1,283 @@ +/** + * @fileoverview Ethereum address validation functions for CLI command parameters. + * + * This module provides validation functions for Ethereum addresses used throughout + * the CLI application. Validates addresses using ethers.js isAddress() which checks + * for valid hexadecimal format and optional checksum validation. + * + * Includes both generic validators and specialized validators for specific address + * types (Safe, owner, token, pool, deployer) with context-specific error messages. + * + * @module validators/addressValidator + */ + +import { ethers } from 'ethers'; +import { VALIDATION_ERRORS } from '../config'; +import { ValidationError } from './ValidationError'; + +/** + * Validates an Ethereum address with generic error message. + * + * Checks if the provided string is a valid Ethereum address (20 bytes, 0x-prefixed hex). + * Accepts both checksummed and non-checksummed addresses. + * + * @param address - The address string to validate + * @param fieldName - Optional field name for error messages (default: 'address') + * @throws {ValidationError} If address format is invalid + * + * @remarks + * Uses ethers.isAddress() which validates: + * - Length is 42 characters (0x + 40 hex chars) + * - All characters are valid hexadecimal + * - Checksum is correct (if checksummed) + * + * Accepts: + * - Checksummed addresses: `0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed` + * - Lowercase addresses: `0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed` + * - Uppercase addresses: `0x5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED` + * + * @example + * ```typescript + * // Valid address + * validateAddress('0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'); + * // No error thrown + * ``` + * + * @example + * ```typescript + * // Invalid address + * try { + * validateAddress('0xInvalidAddress', 'tokenAddress'); + * } catch (error) { + * // ValidationError: Invalid tokenAddress + * console.error(error.message); + * } + * ``` + * + * @example + * ```typescript + * // Custom field name in error message + * try { + * validateAddress('0x123', 'receiver'); + * } catch (error) { + * // ValidationError { + * // message: 'Invalid receiver', + * // field: 'receiver', + * // value: '0x123' + * // } + * } + * ``` + * + * @public + */ +export function validateAddress(address: string, fieldName = 'address'): void { + if (!ethers.isAddress(address)) { + throw new ValidationError(`Invalid ${fieldName}`, fieldName, address); + } +} + +/** + * Validates an optional Ethereum address. + * + * Similar to validateAddress but allows undefined values. Only validates + * if an address is actually provided. + * + * @param address - The address string to validate (can be undefined) + * @param fieldName - Optional field name for error messages (default: 'address') + * @throws {ValidationError} If address is provided but format is invalid + * + * @remarks + * Use this validator when an address parameter is optional in the CLI command. + * If the address is undefined or not provided, no validation error is thrown. + * + * @example + * ```typescript + * // Undefined is valid (optional parameter) + * validateOptionalAddress(undefined, 'tokenPool'); + * // No error thrown + * ``` + * + * @example + * ```typescript + * // Valid address is accepted + * validateOptionalAddress( + * '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', + * 'tokenPool' + * ); + * // No error thrown + * ``` + * + * @example + * ```typescript + * // Invalid address throws error + * try { + * validateOptionalAddress('0xInvalid', 'safe'); + * } catch (error) { + * // ValidationError: Invalid safe + * } + * ``` + * + * @public + */ +export function validateOptionalAddress(address: string | undefined, fieldName = 'address'): void { + if (address && !ethers.isAddress(address)) { + throw new ValidationError(`Invalid ${fieldName}`, fieldName, address); + } +} + +/** + * Type for address validation field names. + * + * Defines the specific address types used in CLI commands. + * + * @internal + */ +type AddressField = 'safe' | 'owner' | 'token' | 'pool' | 'deployer'; + +/** + * Mapping of address fields to their validation error messages. + * + * Provides context-specific error messages from VALIDATION_ERRORS config. + * + * @internal + */ +const ADDRESS_FIELD_ERRORS: Record = { + safe: VALIDATION_ERRORS.INVALID_SAFE_ADDRESS, + owner: VALIDATION_ERRORS.INVALID_OWNER_ADDRESS, + token: VALIDATION_ERRORS.INVALID_TOKEN_ADDRESS, + pool: VALIDATION_ERRORS.INVALID_POOL_ADDRESS, + deployer: VALIDATION_ERRORS.INVALID_DEPLOYER_ADDRESS, +}; + +/** + * Generic validator for specific address fields with custom error messages. + * + * Internal helper function used by specialized address validators to provide + * context-specific error messages. + * + * @param address - The address to validate + * @param field - The field type (safe, owner, token, pool, deployer) + * @throws {ValidationError} If address is invalid, with field-specific error message + * + * @internal + */ +function validateSpecificAddress(address: string, field: AddressField): void { + if (!ethers.isAddress(address)) { + throw new ValidationError(ADDRESS_FIELD_ERRORS[field], field, address); + } +} + +/** + * Validates a Safe multisig contract address. + * + * Specialized validator for Safe addresses with context-specific error message. + * Used when validating the `-s, --safe` CLI parameter. + * + * @param address - The Safe address to validate + * @throws {ValidationError} With message from VALIDATION_ERRORS.INVALID_SAFE_ADDRESS + * + * @example + * ```typescript + * // Valid Safe address + * validateSafeAddress('0x5419c6d83473d1c653e7b51e8568fafedce94f01'); + * ``` + * + * @example + * ```typescript + * // Invalid Safe address + * try { + * validateSafeAddress('0xInvalid'); + * } catch (error) { + * // ValidationError with INVALID_SAFE_ADDRESS message + * } + * ``` + * + * @public + */ +export function validateSafeAddress(address: string): void { + validateSpecificAddress(address, 'safe'); +} + +/** + * Validates a Safe owner address. + * + * Specialized validator for Safe owner addresses with context-specific error message. + * Used when validating the `-w, --owner` CLI parameter. + * + * @param address - The owner address to validate + * @throws {ValidationError} With message from VALIDATION_ERRORS.INVALID_OWNER_ADDRESS + * + * @example + * ```typescript + * // Valid owner address + * validateOwnerAddress('0x0000000000000000000000000000000000000000'); + * ``` + * + * @public + */ +export function validateOwnerAddress(address: string): void { + validateSpecificAddress(address, 'owner'); +} + +/** + * Validates a token contract address. + * + * Specialized validator for token addresses with context-specific error message. + * Used when validating the `-t, --token` CLI parameter. + * + * @param address - The token address to validate + * @throws {ValidationError} With message from VALIDATION_ERRORS.INVALID_TOKEN_ADDRESS + * + * @example + * ```typescript + * // Valid token address + * validateTokenAddress('0x779877A7B0D9E8603169DdbD7836e478b4624789'); + * ``` + * + * @public + */ +export function validateTokenAddress(address: string): void { + validateSpecificAddress(address, 'token'); +} + +/** + * Validates a TokenPool contract address. + * + * Specialized validator for pool addresses with context-specific error message. + * Used when validating the `-p, --pool` CLI parameter. + * + * @param address - The pool address to validate + * @throws {ValidationError} With message from VALIDATION_ERRORS.INVALID_POOL_ADDRESS + * + * @example + * ```typescript + * // Valid pool address + * validatePoolAddress('0x1234567890123456789012345678901234567890'); + * ``` + * + * @public + */ +export function validatePoolAddress(address: string): void { + validateSpecificAddress(address, 'pool'); +} + +/** + * Validates a TokenPoolFactory (deployer) contract address. + * + * Specialized validator for deployer/factory addresses with context-specific error message. + * Used when validating the `-d, --deployer` CLI parameter. + * + * @param address - The deployer/factory address to validate + * @throws {ValidationError} With message from VALIDATION_ERRORS.INVALID_DEPLOYER_ADDRESS + * + * @example + * ```typescript + * // Valid deployer address + * validateDeployerAddress('0x17d8a409fe2cef2d3808bcb61f14abeffc28876e'); + * ``` + * + * @public + */ +export function validateDeployerAddress(address: string): void { + validateSpecificAddress(address, 'deployer'); +} diff --git a/src/validators/commandValidators.ts b/src/validators/commandValidators.ts new file mode 100644 index 0000000..b50777b --- /dev/null +++ b/src/validators/commandValidators.ts @@ -0,0 +1,387 @@ +/** + * @fileoverview Command-specific validation orchestrators for CLI commands. + * + * This module provides validation functions for each CLI command type, orchestrating + * multiple validators (address, salt, format) to ensure all command parameters are + * valid before processing. + * + * Each command validator follows a consistent pattern: + * 1. Validate optional Safe parameters (if provided) + * 2. Validate required command-specific parameters + * 3. Validate Safe JSON format parameters (if format is safe-json) + * + * @module validators/commandValidators + */ + +import { OUTPUT_FORMAT } from '../config'; +import { + validateOptionalAddress, + validateTokenAddress, + validatePoolAddress, + validateDeployerAddress, +} from './addressValidator'; +import { validateSalt } from './saltValidator'; +import { validateSafeJsonParams } from './formatValidator'; + +/** + * Base options interface shared across all commands. + * + * @remarks + * These parameters are optional for calldata format but required for safe-json format. + * + * @internal + */ +interface BaseOptions { + /** Safe multisig contract address (optional for calldata, required for safe-json) */ + safe?: string; + + /** Safe owner address (optional for calldata, required for safe-json) */ + owner?: string; + + /** Chain ID (optional for calldata, required for safe-json) */ + chainId?: string; + + /** Output format: 'calldata' or 'safe-json' */ + format?: string; +} + +/** + * Chain update command options. + * + * @internal + */ +interface ChainUpdateOptions extends BaseOptions { + /** TokenPool address (required for safe-json, optional for calldata) */ + tokenPool?: string; +} + +/** + * Deployment command options (token and pool deployment). + * + * @internal + */ +interface DeploymentOptions extends BaseOptions { + /** TokenPoolFactory address (required) */ + deployer: string; + + /** 32-byte salt for CREATE2 deployment (required) */ + salt: string; +} + +/** + * Mint command options. + * + * @internal + */ +interface MintOptions extends BaseOptions { + /** Token contract address (required) */ + token: string; +} + +/** + * Pool operation command options (allow list, rate limiter). + * + * @internal + */ +interface PoolOperationOptions extends BaseOptions { + /** TokenPool contract address (required) */ + pool: string; +} + +/** + * Grant roles command options. + * + * @internal + */ +interface GrantRolesOptions extends BaseOptions { + /** Token contract address (required) */ + token: string; + + /** Pool contract address (required) */ + pool: string; +} + +/** + * Validates Safe JSON format parameters if format is safe-json. + * + * Internal helper that checks if the output format is safe-json and validates + * the required Safe JSON parameters. + * + * @param options - Command options with format field + * @throws {ValidationError} If format is safe-json and required parameters are missing + * + * @internal + */ +function validateSafeJsonFormat(options: BaseOptions): void { + if (options.format === OUTPUT_FORMAT.SAFE_JSON) { + validateSafeJsonParams(options.chainId, options.safe, options.owner); + } +} + +/** + * Validates optional Safe and owner addresses. + * + * Internal helper that validates Safe and owner addresses if they are provided. + * Does not throw if addresses are undefined. + * + * @param options - Command options with safe and owner fields + * @throws {ValidationError} If addresses are provided but invalid + * + * @internal + */ +function validateOptionalSafeParams(options: BaseOptions): void { + validateOptionalAddress(options.safe, 'Safe address'); + validateOptionalAddress(options.owner, 'owner address'); +} + +/** + * Validates chain update command options. + * + * Validates all parameters for the `generate-chain-update` CLI command. + * + * @param options - Chain update command options + * @throws {ValidationError} If any validation fails + * + * @remarks + * Validation Steps: + * 1. Validate optional Safe and owner addresses (if provided) + * 2. Validate optional TokenPool address (if provided) + * 3. If format is safe-json: Validate chainId, safe, owner are all provided + * + * @example + * ```typescript + * // Valid calldata format + * validateChainUpdateOptions({ + * format: 'calldata', + * tokenPool: '0x1234567890123456789012345678901234567890' + * }); + * ``` + * + * @example + * ```typescript + * // Valid safe-json format + * validateChainUpdateOptions({ + * format: 'safe-json', + * chainId: '84532', + * safe: '0x5419c6d83473d1c653e7b51e8568fafedce94f01', + * owner: '0x0000000000000000000000000000000000000000', + * tokenPool: '0x1234567890123456789012345678901234567890' + * }); + * ``` + * + * @public + */ +export function validateChainUpdateOptions(options: ChainUpdateOptions): void { + validateOptionalSafeParams(options); + validateOptionalAddress(options.tokenPool, 'token pool address'); + validateSafeJsonFormat(options); +} + +/** + * Validates token deployment command options. + * + * Validates all parameters for the `generate-token-deployment` CLI command. + * + * @param options - Token deployment command options + * @throws {ValidationError} If any validation fails + * + * @remarks + * Validation Steps: + * 1. Validate optional Safe and owner addresses (if provided) + * 2. Validate required deployer address format + * 3. Validate required salt value (must be 32 bytes) + * 4. If format is safe-json: Validate chainId, safe, owner are all provided + * + * @example + * ```typescript + * // Valid token deployment + * validateTokenDeploymentOptions({ + * deployer: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * salt: '0x0000000000000000000000000000000000000000000000000000000123456789', + * format: 'safe-json', + * chainId: '84532', + * safe: '0xSafe', + * owner: '0xOwner' + * }); + * ``` + * + * @public + */ +export function validateTokenDeploymentOptions(options: DeploymentOptions): void { + validateOptionalSafeParams(options); + validateDeployerAddress(options.deployer); + validateSalt(options.salt); + validateSafeJsonFormat(options); +} + +/** + * Validates pool deployment command options. + * + * Validates all parameters for the `generate-pool-deployment` CLI command. + * + * @param options - Pool deployment command options + * @throws {ValidationError} If any validation fails + * + * @remarks + * Validation is identical to token deployment (same parameters required). + * + * Validation Steps: + * 1. Validate optional Safe and owner addresses (if provided) + * 2. Validate required deployer address format + * 3. Validate required salt value (must be 32 bytes) + * 4. If format is safe-json: Validate chainId, safe, owner are all provided + * + * @example + * ```typescript + * // Valid pool deployment + * validatePoolDeploymentOptions({ + * deployer: '0x17d8a409fe2cef2d3808bcb61f14abeffc28876e', + * salt: '0x0000000000000000000000000000000000000000000000000000000123456789', + * format: 'calldata' + * }); + * ``` + * + * @public + */ +export function validatePoolDeploymentOptions(options: DeploymentOptions): void { + validateOptionalSafeParams(options); + validateDeployerAddress(options.deployer); + validateSalt(options.salt); + validateSafeJsonFormat(options); +} + +/** + * Validates mint command options. + * + * Validates all parameters for the `generate-mint` CLI command. + * + * @param options - Mint command options + * @throws {ValidationError} If any validation fails + * + * @remarks + * Validation Steps: + * 1. Validate required token address format + * 2. Validate optional Safe and owner addresses (if provided) + * 3. If format is safe-json: Validate chainId, safe, owner are all provided + * + * @example + * ```typescript + * // Valid mint command + * validateMintOptions({ + * token: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + * format: 'safe-json', + * chainId: '84532', + * safe: '0xSafe', + * owner: '0xOwner' + * }); + * ``` + * + * @public + */ +export function validateMintOptions(options: MintOptions): void { + validateTokenAddress(options.token); + validateOptionalSafeParams(options); + validateSafeJsonFormat(options); +} + +/** + * Validates allow list updates command options. + * + * Validates all parameters for the allow list update CLI command. + * + * @param options - Allow list command options + * @throws {ValidationError} If any validation fails + * + * @remarks + * Validation Steps: + * 1. Validate required pool address format + * 2. Validate optional Safe and owner addresses (if provided) + * 3. If format is safe-json: Validate chainId, safe, owner are all provided + * + * @example + * ```typescript + * // Valid allow list update + * validateAllowListOptions({ + * pool: '0x1234567890123456789012345678901234567890', + * format: 'calldata' + * }); + * ``` + * + * @public + */ +export function validateAllowListOptions(options: PoolOperationOptions): void { + validatePoolAddress(options.pool); + validateOptionalSafeParams(options); + validateSafeJsonFormat(options); +} + +/** + * Validates rate limiter configuration command options. + * + * Validates all parameters for the rate limiter configuration CLI command. + * + * @param options - Rate limiter command options + * @throws {ValidationError} If any validation fails + * + * @remarks + * Validation Steps: + * 1. Validate required pool address format + * 2. Validate optional Safe and owner addresses (if provided) + * 3. If format is safe-json: Validate chainId, safe, owner are all provided + * + * @example + * ```typescript + * // Valid rate limiter configuration + * validateRateLimiterOptions({ + * pool: '0x1234567890123456789012345678901234567890', + * format: 'safe-json', + * chainId: '84532', + * safe: '0xSafe', + * owner: '0xOwner' + * }); + * ``` + * + * @public + */ +export function validateRateLimiterOptions(options: PoolOperationOptions): void { + validatePoolAddress(options.pool); + validateOptionalSafeParams(options); + validateSafeJsonFormat(options); +} + +/** + * Validates grant roles command options. + * + * Validates all parameters for the `generate-grant-roles` CLI command. + * + * @param options - Grant roles command options + * @throws {ValidationError} If any validation fails + * + * @remarks + * Validation Steps: + * 1. Validate required token address format + * 2. Validate required pool address format + * 3. Validate optional Safe and owner addresses (if provided) + * 4. If format is safe-json: Validate chainId, safe, owner are all provided + * + * @example + * ```typescript + * // Valid grant roles command + * validateGrantRolesOptions({ + * token: '0x779877A7B0D9E8603169DdbD7836e478b4624789', + * pool: '0x1234567890123456789012345678901234567890', + * format: 'safe-json', + * chainId: '84532', + * safe: '0xSafe', + * owner: '0xOwner' + * }); + * ``` + * + * @public + */ +export function validateGrantRolesOptions(options: GrantRolesOptions): void { + validateTokenAddress(options.token); + validatePoolAddress(options.pool); + validateOptionalSafeParams(options); + validateSafeJsonFormat(options); +} diff --git a/src/validators/formatValidator.ts b/src/validators/formatValidator.ts new file mode 100644 index 0000000..7ed90eb --- /dev/null +++ b/src/validators/formatValidator.ts @@ -0,0 +1,118 @@ +/** + * @fileoverview Validator for Safe Transaction Builder JSON format parameters. + * + * This module validates that all required parameters are provided when using the + * Safe JSON output format (`-f safe-json`). Safe JSON requires three additional + * parameters: chain ID, Safe address, and owner address. + * + * @module validators/formatValidator + */ + +import { VALIDATION_ERRORS } from '../config'; +import { ValidationError } from './ValidationError'; + +/** + * Validates required parameters for Safe Transaction Builder JSON format. + * + * Ensures all three required Safe JSON parameters are provided: chainId, safeAddress, + * and ownerAddress. All three must be present for Safe JSON format generation. + * + * @param chainId - Chain ID where transaction will be executed + * @param safeAddress - Safe multisig contract address + * @param ownerAddress - Owner address executing the transaction + * @throws {ValidationError} If any required parameter is missing + * + * @remarks + * Safe JSON Format Requirements: + * - **chainId**: Chain ID string (e.g., "1" for Ethereum, "84532" for Base Sepolia) + * - **safeAddress**: Address of the Safe multisig contract + * - **ownerAddress**: Address of the Safe owner creating the transaction + * + * These parameters are used to generate Safe Transaction Builder JSON files that + * can be imported into the Safe Transaction Builder UI for multisig signing and + * execution. + * + * CLI Parameters: + * - `-f, --format safe-json`: Enable Safe JSON format + * - `-c, --chain-id `: Chain ID (required with safe-json) + * - `-s, --safe
`: Safe address (required with safe-json) + * - `-w, --owner
`: Owner address (required with safe-json) + * + * @example + * ```typescript + * // All parameters provided - valid + * validateSafeJsonParams( + * "84532", // Base Sepolia + * "0x5419c6d83473d1c653e7b51e8568fafedce94f01", // Safe + * "0x0000000000000000000000000000000000000000" // Owner + * ); + * // No error thrown + * ``` + * + * @example + * ```typescript + * // Missing chainId + * try { + * validateSafeJsonParams( + * undefined, + * "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * "0x0000000000000000000000000000000000000000" + * ); + * } catch (error) { + * // ValidationError: MISSING_SAFE_JSON_PARAMS + * } + * ``` + * + * @example + * ```typescript + * // Missing safeAddress + * try { + * validateSafeJsonParams( + * "84532", + * undefined, + * "0x0000000000000000000000000000000000000000" + * ); + * } catch (error) { + * // ValidationError: MISSING_SAFE_JSON_PARAMS + * } + * ``` + * + * @example + * ```typescript + * // Missing ownerAddress + * try { + * validateSafeJsonParams( + * "84532", + * "0x5419c6d83473d1c653e7b51e8568fafedce94f01", + * undefined + * ); + * } catch (error) { + * // ValidationError: MISSING_SAFE_JSON_PARAMS + * } + * ``` + * + * @example + * ```typescript + * // All missing + * try { + * validateSafeJsonParams(undefined, undefined, undefined); + * } catch (error) { + * // ValidationError: When using safe-json format, chain ID, Safe address, + * // and owner address are required + * } + * ``` + * + * @see {@link VALIDATION_ERRORS.MISSING_SAFE_JSON_PARAMS} for error message + * @see {@link validateChainUpdateOptions} for usage in command validation + * + * @public + */ +export function validateSafeJsonParams( + chainId: string | undefined, + safeAddress: string | undefined, + ownerAddress: string | undefined, +): void { + if (!chainId || !safeAddress || !ownerAddress) { + throw new ValidationError(VALIDATION_ERRORS.MISSING_SAFE_JSON_PARAMS); + } +} diff --git a/src/validators/index.ts b/src/validators/index.ts new file mode 100644 index 0000000..95bcdb9 --- /dev/null +++ b/src/validators/index.ts @@ -0,0 +1,15 @@ +/** + * @fileoverview Validation module barrel export. + * + * This module re-exports all validation functions, error classes, and types + * from the validators package. Provides a single import point for all validation + * functionality used throughout the application. + * + * @module validators + */ + +export * from './ValidationError'; +export * from './addressValidator'; +export * from './saltValidator'; +export * from './formatValidator'; +export * from './commandValidators'; diff --git a/src/validators/saltValidator.ts b/src/validators/saltValidator.ts new file mode 100644 index 0000000..2cce581 --- /dev/null +++ b/src/validators/saltValidator.ts @@ -0,0 +1,107 @@ +/** + * @fileoverview Salt validation for CREATE2 deterministic contract deployments. + * + * This module validates salt values used in CREATE2 deployments via the TokenPoolFactory. + * The salt must be exactly 32 bytes (66 characters including 0x prefix) to ensure + * deterministic address computation. + * + * The TokenPoolFactory modifies the salt by hashing it with msg.sender before CREATE2 + * deployment, enabling multiple users to use the same salt value without collision. + * + * @module validators/saltValidator + */ + +import { ethers } from 'ethers'; +import { VALIDATION_ERRORS, VALIDATION_RULES } from '../config'; +import { ValidationError } from './ValidationError'; + +/** + * Validates a salt value for CREATE2 deployment. + * + * Ensures the salt is exactly 32 bytes (66 hex characters including 0x prefix). + * This length requirement is critical for CREATE2 address computation. + * + * @param salt - The salt value to validate (hex string) + * @throws {ValidationError} If salt is missing or not exactly 32 bytes + * + * @remarks + * Salt Requirements: + * - Must be provided (not undefined or empty) + * - Must be exactly 32 bytes + * - Format: `0x` + 64 hexadecimal characters + * - Total length: 66 characters + * + * CREATE2 Address Computation: + * 1. Factory modifies salt: `modifiedSalt = keccak256(abi.encodePacked(salt, msg.sender))` + * 2. Compute address: `keccak256(0xff ++ deployer ++ modifiedSalt ++ keccak256(initCode))` + * + * Salt Modification: + * - The TokenPoolFactory hashes the salt with msg.sender + * - This allows different users to use the same salt value + * - Each user gets a different deployed address for the same salt + * + * @example + * ```typescript + * // Valid salt (32 bytes) + * validateSalt('0x0000000000000000000000000000000000000000000000000000000123456789'); + * // No error thrown + * ``` + * + * @example + * ```typescript + * // Another valid salt (all zeros) + * validateSalt('0x0000000000000000000000000000000000000000000000000000000000000000'); + * // No error thrown + * ``` + * + * @example + * ```typescript + * // Missing salt + * try { + * validateSalt(undefined); + * } catch (error) { + * // ValidationError: Salt is required + * // field: 'salt' + * } + * ``` + * + * @example + * ```typescript + * // Invalid length (too short) + * try { + * validateSalt('0x1234'); + * } catch (error) { + * // ValidationError: Salt must be a 32-byte hex string + * // field: 'salt' + * // value: '0x1234' + * } + * ``` + * + * @example + * ```typescript + * // Invalid length (too long) + * try { + * validateSalt('0x' + '00'.repeat(33)); // 33 bytes + * } catch (error) { + * // ValidationError: Salt must be a 32-byte hex string + * } + * ``` + * + * @see {@link VALIDATION_RULES.SALT_BYTE_LENGTH} for required salt length (32 bytes) + * @see {@link createAddressComputer} for CREATE2 address computation with salt + * + * @public + */ +export function validateSalt(salt: string | undefined): void { + if (!salt) { + throw new ValidationError(VALIDATION_ERRORS.SALT_REQUIRED, 'salt'); + } + + if (ethers.dataLength(salt) !== VALIDATION_RULES.SALT_BYTE_LENGTH) { + throw new ValidationError( + 'Salt must be a 32-byte hex string (0x followed by 64 hex characters)', + 'salt', + salt, + ); + } +}