diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..ad6f18a --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.env.example b/.env.example index 9ffa0a9..eacb3e5 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,81 @@ -# Private keys for each chain type -EVM_PRIVATE_KEY=0x... -TVM_PRIVATE_KEY=... -SVM_PRIVATE_KEY=... - -# RPC endpoints (optional - defaults provided) -EVM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/... -TVM_RPC_URL=https://api.trongrid.io -SVM_RPC_URL=https://api.mainnet-beta.solana.com - -# Quote Service Configuration (optional) -# Use solver-v2 endpoint (takes precedence over quote service) -SOLVER_URL=https://solver.example.com - -# Use preprod quote service (only applies if SOLVER_URL not set) -# QUOTES_API_URL=any_value -# QUOTES_PREPROD=any_value - -# Portal contract addresses per chain (optional - defaults provided) -PORTAL_ADDRESS_ETH=0x... -PORTAL_ADDRESS_OPTIMISM=0x... -PORTAL_ADDRESS_TRON=... -PORTAL_ADDRESS_SOLANA=... \ No newline at end of file +# ============================================================================= +# Routes CLI — Environment Configuration +# ============================================================================= +# Copy this file to .env and fill in the values for your setup. +# +# REQUIRED variables must be set before publishing to the corresponding chain. +# OPTIONAL variables override built-in defaults; omitting them is safe. +# +# Private key format summary: +# EVM — 0x followed by 64 hexadecimal characters (66 chars total) +# TVM — 64 hexadecimal characters WITHOUT the 0x prefix (64 chars) +# SVM — base58 string, JSON array [1,2,...], or comma-separated bytes +# ============================================================================= + + +# ============================================================================= +# REQUIRED: Private Keys +# Set the key for each chain type you intend to publish intents on. +# ============================================================================= + +# EVM chains (Ethereum, Base, Optimism, Arbitrum, Polygon, BSC, Ronin, etc.) +# Format : 0x + 64 hexadecimal characters (66 characters total) +# Example: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +EVM_PRIVATE_KEY= + +# TVM chain (Tron mainnet / Tron Shasta testnet) +# Format : 64 hexadecimal characters WITHOUT the 0x prefix (64 characters) +# NOTE : Do NOT use your TRX wallet address (starts with T); use the raw hex key +# Example: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +TVM_PRIVATE_KEY= + +# SVM chain (Solana mainnet / Solana Devnet) +# Three accepted formats (choose one): +# +# Base58 string (default export from Phantom, Solana CLI `solana-keygen`): +# SVM_PRIVATE_KEY=5K5K5K...base58EncodedKey +# +# JSON byte array (Solana CLI keypair file format): +# SVM_PRIVATE_KEY=[1,2,3,4,...,64] +# +# Comma-separated byte values: +# SVM_PRIVATE_KEY=1,2,3,4,...,64 +SVM_PRIVATE_KEY= + + +# ============================================================================= +# OPTIONAL: RPC Endpoint Overrides +# Each chain has a built-in default RPC URL. Set these to use a private +# or rate-limit-free endpoint (Alchemy, Infura, QuickNode, etc.). +# ============================================================================= + +# EVM chains — override per chain using EVM_RPC_URL_{CHAIN_ID}: +# EVM_RPC_URL_1=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY # Ethereum +# EVM_RPC_URL_10=https://opt-mainnet.g.alchemy.com/v2/YOUR_KEY # Optimism +# EVM_RPC_URL_8453=https://base-mainnet.g.alchemy.com/v2/YOUR_KEY # Base +# EVM_RPC_URL_42161=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY # Arbitrum + +# Tron — default: https://api.trongrid.io +# TVM_RPC_URL=https://api.trongrid.io + +# Solana — default: https://api.mainnet-beta.solana.com +# SVM_RPC_URL=https://api.mainnet-beta.solana.com + + +# ============================================================================= +# OPTIONAL: Quote Service Selection +# Controls which pricing endpoint is used for route quotes. +# +# Priority order (highest first): +# 1. SOLVER_URL — solver-v2 API at {SOLVER_URL}/api/v2/quote/reverse +# 2. QUOTES_PREPROD — preprod service at https://quotes-preprod.eco.com/... +# 3. (default) — production service at https://quotes.eco.com/... +# +# Unset all three to use the default production service. +# ============================================================================= + +# Solver v2 endpoint (takes precedence over all other quote service settings) +# SOLVER_URL=https://your-solver.example.com + +# Set to any non-empty value to force the preprod quote service (ignored if SOLVER_URL is set) +# QUOTES_PREPROD=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e389172 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI +on: + push: + branches: [main] + tags: + - 'v*' + pull_request: + branches: [main] +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test:coverage + - run: pnpm build + - run: pnpm audit --audit-level=moderate + e2e: + runs-on: ubuntu-latest + needs: quality + env: + FORK_RPC_URL: ${{ secrets.BASE_RPC_URL }} + FORK_BLOCK_NUMBER: '28000000' + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Run E2E tests (Anvil managed by Jest global setup/teardown) + run: pnpm test:e2e:ci + release: + runs-on: ubuntu-latest + needs: quality + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Publish to npm + run: pnpm publish --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + docs: + runs-on: ubuntu-latest + needs: quality + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - name: Generate API docs + run: pnpm docs + - uses: actions/configure-pages@v4 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/api + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..e2c4b14 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,30 @@ +name: Security Scan +on: + schedule: + - cron: '0 2 * * *' + push: + branches: [main] +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm audit --audit-level=high + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: trufflesecurity/trufflehog@main + with: + path: ./ + base: ${{ github.event.repository.default_branch }} + head: HEAD + extra_args: --debug --only-verified diff --git a/.gitignore b/.gitignore index 92eb06c..a72d361 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ node_modules/ # Build output dist/ +bundle/ + +# Generated docs +docs/api/ # Environment files .env @@ -33,3 +37,4 @@ coverage/ CLAUDE.md thoughts/ +.worktrees/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc5..e98fb4d 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ npx lint-staged +pnpm typecheck diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +18 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d5461bd --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1123 @@ +# Routes CLI — Architecture Document + +> Generated 2026-02-20. Intended as the foundation for the upcoming architecture improvement initiative. +> For a developer how-to guide on adding new chains, see the "Quick Reference" at the bottom. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [High-Level Architecture](#2-high-level-architecture) +3. [Core Type System](#3-core-type-system) +4. [Universal Address System](#4-universal-address-system) +5. [Configuration Layer](#5-configuration-layer) +6. [Intent Construction](#6-intent-construction) +7. [Blockchain Publisher Layer](#7-blockchain-publisher-layer) +8. [CLI Layer](#8-cli-layer) +9. [Security Architecture](#9-security-architecture) +10. [Data & Control Flows](#10-data--control-flows) +11. [Module Dependency Graph](#11-module-dependency-graph) +12. [Build System](#12-build-system) +13. [Supported Chains & Tokens](#13-supported-chains--tokens) +14. [Known Issues & Improvement Opportunities](#14-known-issues--improvement-opportunities) +15. [Quick Reference: Adding a New Chain](#15-quick-reference-adding-a-new-chain) + +--- + +## 1. Overview + +Routes CLI is a command-line tool for publishing **cross-chain intents** on EVM, TVM (Tron), and SVM (Solana) blockchains. Built by Eco Protocol, it lets users specify a **reward** on a source chain in exchange for a solver executing a **route** on a destination chain. + +**Core Concepts:** +- **Intent** = Route (what to do on destination) + Reward (what to pay on source) +- **Route** = A series of smart contract calls to execute on the destination chain +- **Reward** = Tokens/native currency locked on the source chain as solver incentive +- **Universal Address** = 32-byte chain-agnostic address format used throughout all internal logic +- **Publisher** = Chain-specific class responsible for broadcasting the intent transaction + +--- + +## 2. High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CLI LAYER │ +│ src/index.ts → commands/ → cli/prompts/ → utils/logger.ts │ +└───────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ INTENT SERVICE LAYER │ +│ src/core/services/intent-service.ts │ +│ ├── Quote fetching (src/core/utils/quote.ts) │ +│ └── Manual fallback route building │ +└───────────────────────────┬─────────────────────────────────────────┘ + │ + ┌─────────────┴──────────────┐ + ▼ ▼ +┌─────────────────────┐ ┌────────────────────────────────────────┐ +│ ENCODING LAYER │ │ CONFIGURATION LAYER │ +│ portal-encoder.ts │ │ config/chains.ts config/tokens.ts │ +│ intent-converter │ │ config/env.ts chain-registry.ts │ +└─────────────────────┘ └────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ PUBLISHER LAYER │ +│ BasePublisher (abstract) │ +│ ├── EVMPublisher → viem (PublicClient + WalletClient) │ +│ ├── TVMPublisher → TronWeb │ +│ └── SVMPublisher → @solana/web3.js + Anchor │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ ADDRESS SYSTEM │ +│ AddressNormalizer ── UniversalAddress ── ChainRegistry │ +│ (normalize / denormalize at all chain boundaries) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Key principle:** All internal data flows in `UniversalAddress` format. Denormalization to chain-native formats happens *only* inside publisher classes and *only* just before blockchain calls. + +--- + +## 3. Core Type System + +### ChainType Enum + +```typescript +// src/core/interfaces/intent.ts +enum ChainType { + EVM = 'EVM', // Ethereum-compatible chains + TVM = 'TVM', // Tron Virtual Machine + SVM = 'SVM', // Solana Virtual Machine +} +``` + +### Intent Interface + +The central data structure of the system. **All addresses stored as UniversalAddress.** + +```typescript +interface Intent { + intentHash?: Hex; // Computed after creation + destination: bigint; // Destination chain ID + sourceChainId: bigint; // Source chain ID + + route: { + salt: Hex; // Random 32-byte replay protection + deadline: bigint; // Unix seconds — route execution deadline + portal: UniversalAddress; // Portal contract on destination chain + nativeAmount: bigint; // Native token for route execution + tokens: Array<{ amount: bigint; token: UniversalAddress }>; + calls: Array<{ + data: Hex; + target: UniversalAddress; + value: bigint; + }>; + }; + + reward: { + deadline: bigint; // Unix seconds — reward claiming deadline + creator: UniversalAddress; + prover: UniversalAddress; // Authorized prover/solver + nativeAmount: bigint; + tokens: Array<{ amount: bigint; token: UniversalAddress }>; + }; +} +``` + +### Blockchain Address Types + +```typescript +type EvmAddress = Address; // viem Address (0x + 40 hex) +type TronAddress = `T${string}`; // Base58, starts with 'T' +type SvmAddress = string & { _brand: 'SvmAddress' }; // Base58 Solana pubkey +type BlockchainAddress = EvmAddress | TronAddress | SvmAddress; +``` + +### Configuration Types + +```typescript +interface ChainConfig { + id: bigint; + name: string; + env: 'production' | 'development'; + type: ChainType; + rpcUrl: string; + portalAddress?: UniversalAddress; + proverAddress?: UniversalAddress; + nativeCurrency: { name: string; symbol: string; decimals: number }; +} + +interface TokenConfig { + symbol: string; + name: string; + decimals: number; + addresses: Record; // key = chainId.toString() +} +``` + +### Publisher Result Types + +```typescript +interface PublishResult { + success: boolean; + transactionHash?: string; + intentHash?: string; + error?: string; + vaultAddress?: string; // EVM vault address created on publish + decodedData?: unknown; // SVM program output +} + +interface ValidationResult { + valid: boolean; + errors: string[]; +} +``` + +### Error Types + +```typescript +enum ErrorCode { + INVALID_ADDRESS = 'INVALID_ADDRESS', + INVALID_PRIVATE_KEY = 'INVALID_PRIVATE_KEY', + INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', + UNSUPPORTED_CHAIN = 'UNSUPPORTED_CHAIN', + NETWORK_ERROR = 'NETWORK_ERROR', + TRANSACTION_FAILED = 'TRANSACTION_FAILED', + CONFIGURATION_ERROR = 'CONFIGURATION_ERROR', + QUOTE_SERVICE_ERROR = 'QUOTE_SERVICE_ERROR', +} + +class RoutesCliError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + public readonly isUserError: boolean = false, + public readonly cause?: unknown + ) + // Factory methods: invalidAddress(), invalidPrivateKey(), + // insufficientBalance(), unsupportedChain(), etc. +} +``` + +### Validation Schemas (Zod) + +| Schema | Pattern | +|--------|---------| +| `EvmAddressSchema` | `/^0x[a-fA-F0-9]{40}$/` | +| `UniversalAddressSchema` | `/^0x[a-fA-F0-9]{64}$/` | +| `TvmAddressSchema` | Base58 `T[34 chars]` or hex `0x41[40 hex]` | +| `SvmAddressSchema` | Base58, 32–44 chars | +| `EvmPrivateKeySchema` | `/^0x[a-fA-F0-9]{64}$/` | +| `TvmPrivateKeySchema` | `/^[a-fA-F0-9]{64}$/` (no `0x`) | +| `TokenAmountSchema` | Positive decimal string | +| `ChainIdSchema` | Positive bigint | + +--- + +## 4. Universal Address System + +### Design Rationale + +The intent system is *cross-chain*. A single `Intent` struct contains addresses from multiple chains (e.g. the creator on Ethereum, the portal on Solana). A single 32-byte address format eliminates `switch (chainType)` statements throughout the codebase and makes the `Intent` interface chain-agnostic. + +### Format + +``` +UniversalAddress = "0x" + 64 hex characters (32 bytes) + +EVM (20 bytes, zero-padded): + 0x000000000000000000000000742d35cc6634c0532925a3b8d65c32c2b3f6de1b + ^^^^^^^^^^^^^^^^^^^^^^^^ 12 zero bytes ^^^^^^^^^^^^^^^^^^^^^^^^ 20-byte EVM addr + +TVM (21 bytes, Tron 0x41 prefix, padded): + 0x0000000000000000000000004166b86ac24bd89bf2e8c33a3a6c4b63d5c4acef + +SVM (32 bytes, no padding): + 0xc69a84e3e1abff0111b65bc2daa7a8b6b2a0cec08d6c6d2c1b2f0e4cb3de5f7 +``` + +### Normalization Rules + +| Chain | Native Format | Universal Encoding | +|-------|-------------|-------------------| +| EVM | `0x` + 40 hex (checksummed) | Left-pad to 32 bytes with zeros | +| TVM | Base58 `T...` | Convert to hex `0x41...`, pad to 32 bytes | +| SVM | Base58 (32-byte pubkey) | Raw 32-byte pubkey as hex | + +### AddressNormalizer API (`src/core/utils/address-normalizer.ts`) + +```typescript +// Normalize: chain-native → UniversalAddress +AddressNormalizer.normalize(address, chainType) // generic dispatch +AddressNormalizer.normalizeEvm(evmAddress) +AddressNormalizer.normalizeTvm(tronAddress) +AddressNormalizer.normalizeSvm(solanaAddress | PublicKey) + +// Denormalize: UniversalAddress → chain-native +AddressNormalizer.denormalize(universal, chainType) // generic, type-safe return +AddressNormalizer.denormalizeToEvm(universal) → EvmAddress +AddressNormalizer.denormalizeToTvm(universal) → TronAddress +AddressNormalizer.denormalizeToSvm(universal) → SvmAddress +``` + +### Address Flow Rule (Critical) + +``` +User Input Internal Logic Blockchain / Display +(chain-native) → (UniversalAddress) → (chain-native) + normalize() denormalize() +``` + +**Where to denormalize:** +- Inside publisher `publish()` — before any RPC or contract call +- Before displaying addresses to users in CLI output +- When calling external APIs that expect chain-native formats + +**Where NOT to denormalize:** +- In the `Intent` struct +- In `chains.ts` / `tokens.ts` configuration +- When passing addresses between internal functions + +### ChainHandler & ChainRegistry + +Each chain type has a self-registering handler module: + +```typescript +interface ChainHandler { + readonly chainType: ChainType; + validateAddress(address: string): boolean; + normalize(address: string): UniversalAddress; + denormalize(address: UniversalAddress): BlockchainAddress; + getAddressFormat(): string; +} + +// Singleton, populated by side-effect imports in src/index.ts: +export const chainRegistry = new ChainRegistry(); +``` + +> **⚠️ Import order is critical.** `chains.ts` and `tokens.ts` call `AddressNormalizer.normalize()` +> at module load time. If chain handlers are not registered first the process throws on startup. +> The three handler imports in `src/index.ts` must always precede all other `@/` imports. + +--- + +## 5. Configuration Layer + +### Chain Configuration (`src/config/chains.ts`) + +Chains are partitioned by `env`: +- **`production`** — loaded by default +- **`development`** — loaded when `NODE_CHAINS_ENV=development` + +**Portal address env overrides:** `PORTAL_ADDRESS_ETH`, `PORTAL_ADDRESS_OPTIMISM`, `PORTAL_ADDRESS_BASE`, `PORTAL_ADDRESS_TRON`, `PORTAL_ADDRESS_SOLANA` + +### Token Configuration (`src/config/tokens.ts`) + +Tokens have addresses per chain, keyed by `chainId.toString()`. All addresses are auto-normalized to `UniversalAddress` at module load via `AddressNormalizer.normalize()`. + +### Environment Configuration (`src/config/env.ts`) + +All variables validated with Zod at startup. Unknown or malformed variables throw `RoutesCliError.configurationError`. + +``` +EVM_PRIVATE_KEY 0x + 64 hex chars (required for EVM) +TVM_PRIVATE_KEY 64 hex chars, no 0x (required for TVM) +SVM_PRIVATE_KEY Base58 | [1,2,...] | 1,2,... (required for SVM) + +EVM_RPC_URL optional override +TVM_RPC_URL default: https://api.trongrid.io +TVM_RPC_URL_2 default: https://tron.publicnode.com (fallback) +SVM_RPC_URL default: https://api.mainnet-beta.solana.com +SVM_RPC_URL_2 default: https://solana.publicnode.com (fallback) +SOLVER_URL optional; enables solver-v2 quote endpoint +QUOTES_API_URL optional; enables preprod quote endpoint +NODE_CHAINS_ENV 'production' (default) | 'development' +DEBUG optional; enables verbose logging + stack traces +``` + +### Persistent CLI Config (`~/.eco-routes/config.json`) + +Managed by the `config` command. Supports named profiles. + +```json +{ + "defaultSourceChain": "base", + "defaultDestinationChain": "optimism", + "defaultPrivateKeys": { "EVM": "...", "TVM": "...", "SVM": "..." }, + "rpcUrls": { "base": "https://..." }, + "profiles": { "mainnet": {}, "testnet": {} }, + "currentProfile": "mainnet" +} +``` + +--- + +## 6. Intent Construction + +### IntentService (`src/core/services/intent-service.ts`) + +Central orchestrator for building intents. Coordinates quote fetching, manual fallback, user confirmation, and final reward/route assembly. + +```typescript +interface IntentConfig { + sourceChain: ChainConfig; + destChain: ChainConfig; + creator: UniversalAddress; + recipient: UniversalAddress; + rewardToken: { address: BlockchainAddress; decimals: number; symbol?: string }; + rewardAmount: bigint; + rewardAmountStr: string; + routeToken: { address: BlockchainAddress; decimals: number; symbol?: string }; +} + +interface BuildIntentResult { + reward: Intent['reward']; + encodedRoute: Hex; + sourcePortal: UniversalAddress; +} +``` + +#### buildIntent() Flow + +``` +IntentService.buildIntent(config: IntentConfig) + │ + ├─► Phase 1: getQuoteOrFallback() + │ ├─ Calls quote service (see §6.1 below) + │ └─ On failure: manual portal/prover prompts + manual route encoding + │ + ├─► Phase 2: Construct reward object + │ └─ { deadline, creator, prover (UniversalAddress), nativeAmount: 0, tokens } + │ + ├─► Phase 3: Display "📋 Intent Summary" table + │ + ├─► Phase 4: User confirmation prompt (default: true) + │ └─ Throws "Publication cancelled by user" if denied + │ + └─► Return: { reward, encodedRoute, sourcePortal } +``` + +### Quote Service (`src/core/utils/quote.ts`) + +**Endpoint selection (priority order):** +1. `SOLVER_URL` → `{SOLVER_URL}/api/v2/quote/reverse` +2. `QUOTES_API_URL` or `QUOTES_PREPROD` → `https://quotes-preprod.eco.com/api/v3/quotes/single` +3. Default → `https://quotes.eco.com/api/v3/quotes/single` + +**Request:** +```typescript +{ + dAppID: 'eco-routes-cli', + quoteRequest: { + sourceChainID, sourceToken, destinationChainID, destinationToken, + sourceAmount, funder, recipient // all in chain-native format + } +} +``` + +**Response (both formats normalized internally):** +```typescript +{ + quoteResponse: { encodedRoute, deadline, destinationAmount, estimatedFulfillTimeSec?, ... }, + contracts: { sourcePortal: Address, prover: Address, destinationPortal: Address } +} +``` + +### PortalEncoder (`src/core/utils/portal-encoder.ts`) + +Encodes Route and Reward structs for the target chain. Handles address denormalization internally. + +| Chain | Encoding | Library | +|-------|----------|---------| +| EVM | ABI encoding | viem `encodeAbiParameters` | +| TVM | ABI encoding (same as EVM) | viem `encodeAbiParameters` | +| SVM | Borsh serialization | `portalBorshCoder` | + +```typescript +PortalEncoder.encode(route | reward, chainType): Hex +PortalEncoder.decode(data, chainType, 'route' | 'reward'): Route | Reward +PortalEncoder.isRoute(data): data is Route // type guard +``` + +### IntentConverter (`src/core/utils/intent-converter.ts`) + +Converts the universal-address-based `Intent` to EVM-native format before ABI encoding: + +```typescript +toEVMIntent(intent: Intent): { route: EVMRoute; reward: EVMReward; ... } +toRewardEVMIntent(reward: Intent['reward']): EVMReward +toRouteEVMIntent(route: Intent['route']): EVMRoute +``` + +--- + +## 7. Blockchain Publisher Layer + +### Class Hierarchy + +``` +BasePublisher (abstract) +├── EVMPublisher viem PublicClient + WalletClient +├── TVMPublisher TronWeb +└── SVMPublisher @solana/web3.js Connection + Anchor Program +``` + +### BasePublisher Contract + +```typescript +abstract class BasePublisher { + constructor(rpcUrl: string) + + abstract publish( + source: bigint, destination: bigint, + reward: Intent['reward'], encodedRoute: string, + keyHandle: KeyHandle, + portalAddress?: UniversalAddress, proverAddress?: UniversalAddress + ): Promise + + abstract getBalance(address: string, chainId?: bigint): Promise + abstract validate(reward: Intent['reward'], senderAddress: string): Promise + + protected handleError(error: unknown): PublishResult + protected runSafely(fn: () => Promise): Promise + protected runPreflightChecks(sourceChainId: bigint): void // validates allowlist +} +``` + +**Factory function:** +```typescript +createPublisher(chainType: ChainType, rpcUrl: string): BasePublisher +``` + +Each publisher accepts an optional client factory constructor parameter for testability: +```typescript +new EVMPublisher(rpcUrl, mockEvmClientFactory) +new TVMPublisher(rpcUrl, mockTvmClientFactory) +new SVMPublisher(rpcUrl, mockSvmClientFactory) +``` + +### EVMPublisher + +**Tech:** viem + +**Client strategy:** +- Cached `PublicClient` for all reads (balance checks, allowances, validation) +- Fresh `WalletClient` created per `publish()` call for signing + +**Publish sequence:** +1. Preflight check (chain ID in allowlist) +2. Derive account from `KeyHandle` via `privateKeyToAccount(key)` +3. For each reward token: check balance → check allowance → `approve(portal, maxUint256)` if needed (wait 2 confirmations) +4. Denormalize all addresses (UniversalAddress → checksummed EVM) +5. `encodeFunctionData("publishAndFund", [destination, encodedRoute, evmReward, false])` +6. `walletClient.sendTransaction({ to: portalAddress, data, value: nativeAmount })` +7. Wait for receipt; parse `IntentPublished` event logs +8. Return `{ success, transactionHash, intentHash }` + +**Contract functions called on Portal:** +```solidity +function publishAndFund(uint64 destination, bytes route, Reward reward, bool allowPartial) + external payable returns (bytes32 intentHash, address vault) +``` + +**Contract functions called on ERC-20:** +```solidity +function balanceOf(address account) view returns (uint256) +function allowance(address owner, address spender) view returns (uint256) +function approve(address spender, uint256 amount) returns (bool) +``` + +### TVMPublisher + +**Tech:** TronWeb + +**Key security invariant:** Private key is set on the TronWeb instance immediately before use and always cleared in a `finally` block. + +**Publish sequence:** +1. Preflight check +2. `keyHandle.use(key => { tronWeb.setPrivateKey(key); ... })` — key captured synchronously +3. `try {` For each reward token: TRC-20 `approve(portal, amount)` → poll confirmation (20 × 4s) +4. Denormalize addresses (UniversalAddress → Base58 Tron) +5. Call Portal `publishAndFund(dest, encodedRoute, tvmReward, false)` with TRX `callValue` +6. Compute `intentHash` locally via `PortalHashUtils` +7. `} finally { tronWeb.setPrivateKey('') }` + +**TVM invariant:** At least one reward token is required (Tron Portal does not support native-only rewards). + +**Transaction confirmation polling:** 20 attempts × 4-second interval. Checks `txInfo.blockNumber && receipt.result === 'SUCCESS'`. + +### SVMPublisher + +**Tech:** `@solana/web3.js` + `@coral-xyz/anchor` + +**Private key formats supported:** +1. JSON byte array: `[1, 2, 3, ...]` +2. Comma-separated: `1, 2, 3, ...` +3. Base58 string (default Solana/Phantom export format) + +**Publish sequence:** +1. Preflight check +2. Parse `Keypair` from `KeyHandle` +3. Derive portal `PublicKey` from chain config +4. Calculate `intentHash` and `routeHash` via `PortalHashUtils` +5. `setupAnchorProgram(connection, context)` → Anchor `Program` instance +6. `buildFundingTransaction()`: + - Derive vault PDA: `["vault", intentHashBytes]` + - Derive associated token accounts (funder ATA + vault ATA per token) + - `program.methods.fund({ destination, routeHash, reward, allowPartial })` + - Set `{ vault, payer, funder }` accounts + remaining token accounts +7. `sendAndConfirmTransaction()`: `skipPreflight: false`, `maxRetries: 3`, poll 30× at 1s intervals until `'confirmed'` +8. Return `{ success, transactionHash, intentHash }` + +**PDA Derivation:** +``` +vault PDA: ["vault", intentHash bytes] +proof PDA: ["proof", intentHash bytes, proverAddress bytes] +withdrawn_marker PDA: ["withdrawn_marker", intentHash bytes] +``` + +**Connection config:** +```typescript +{ + commitment: 'confirmed', + disableRetryOnRateLimit: true, + confirmTransactionInitialTimeout: 60000, +} +``` + +### Portal Contract ABIs (`src/commons/abis/`) + +Key Solidity signatures: + +```solidity +// Portal contract +function publishAndFund( + uint64 destination, + bytes memory route, + Reward memory reward, + bool allowPartial +) external payable returns (bytes32 intentHash, address vault) + +function fund( + uint64 destination, + bytes32 routeHash, + Reward memory reward, + bool allowPartial +) external payable returns (bytes32 intentHash) + +event IntentPublished( + bytes32 indexed intentHash, + uint64 destination, + bytes route, + address indexed creator, + address indexed prover, + uint64 rewardDeadline, + uint256 rewardNativeAmount, + TokenAmount[] rewardTokens +) + +// Reward struct +struct Reward { + uint64 deadline; + address creator; + address prover; + uint256 nativeAmount; + TokenAmount[] tokens; +} +struct TokenAmount { address token; uint256 amount; } +``` + +--- + +## 8. CLI Layer + +### Entry Point (`src/index.ts`) + +**Import order is critical and must not be changed:** + +```typescript +// MUST come first — populates chainRegistry before chains.ts/tokens.ts load: +import '@/blockchain/evm/evm-chain-handler'; +import '@/blockchain/tvm/tvm-chain-handler'; +import '@/blockchain/svm/svm-chain-handler'; + +// Only then can config files be imported: +import { listChains } from '@/config/chains'; +import { listTokens } from '@/config/tokens'; +``` + +**Startup sequence:** +1. Node >= 18 version check (exits with code 1 if failed) +2. `setupGlobalErrorHandlers()` — uncaught exceptions, unhandled rejections, SIGTERM/SIGINT +3. `ConfigService.fromEnvironment()` — loads and validates `.env` +4. Register all chain IDs in `chainRegistry` (security allowlist) +5. Create Commander program, register all commands +6. `program.parse(argv)` + +### Commands + +#### `publish` — Main Command + +``` +routes-cli publish [options] + +Options: + -s, --source Source chain name or ID + -d, --destination Destination chain name or ID + -k, --private-key Override env private key + -r, --rpc Override RPC endpoint + --recipient
Recipient on destination chain + --dry-run Validate only, do not broadcast +``` + +**Interactive publish flow:** + +``` +1. "🎨 Interactive Intent Publishing" +2. PROMPT: Select source chain (list of all chains) +3. PROMPT: Select destination chain (all except source) +4. SECTION: "📏 Route Configuration (Destination Chain)" +5. PROMPT: Select route token (tokens on dest chain, or custom address+decimals) +6. SECTION: "💰 Reward Configuration (Source Chain)" +7. PROMPT: Select reward token +8. PROMPT: Enter reward amount → parseUnits(str, decimals) → bigint +9. SECTION: "👤 Recipient Configuration" +10. PROMPT: Enter recipient address → validate → normalize to UniversalAddress +11. DERIVE: keyHandle.use(rawKey => ({ + senderNative: getWalletAddress(chainType, rawKey), + publishKeyHandle: new KeyHandle(rawKey) + })) +12. BUILD: IntentService.buildIntent() + ├── getQuoteOrFallback() with spinner + ├── Display "📋 Intent Summary" table + └── CONFIRM: "Publish this intent?" (default: true) +13. CHECK: --dry-run → log warning and exit +14. CREATE: createPublisher(sourceChain.type, rpcUrl) +15. SPINNER: "Publishing intent to blockchain..." +16. CALL: publisher.publish(source, dest, reward, encodedRoute, keyHandle, portal) +17. DISPLAY: Transaction result table (hash, intent hash, vault address) +``` + +#### `status` — Check Intent Status + +``` +routes-cli status -c [--watch] [--json] [--verbose] +``` + +Queries Portal contract for `IntentFulfilled` events. Currently **EVM-only**. Watch mode polls every 10 seconds. + +#### `chains` — List Chains + +Inline command. Displays table: Name, ID, Type, Native Currency. + +#### `tokens` — List Tokens + +Inline command. Displays table: Symbol, Name, Decimals, Available Chains. + +#### `config` — Manage CLI Configuration + +Subcommands: `list`, `set [-i]`, `get `, `unset `, `reset [--force]` +Profile management: `profile create|switch|delete|list` + +### Logger (`src/utils/logger.ts`) + +Singleton `logger` instance wrapping `ora` (spinners) and `cli-table3` (tables). + +**Spinner lifecycle:** `spinner(text)` → `succeed/fail/warn/info(text?)` + +**Display methods:** + +| Method | Color | Prefix | +|--------|-------|--------| +| `success(msg)` | Green | ✅ | +| `error(msg)` | Red | ❌ | +| `warning(msg)` | Yellow | ⚠️ | +| `log(msg)` | Gray | — | +| `title(msg)` | Bold blue | — | +| `section(msg)` | Blue | — | + +**Table methods:** `displayTable(headers, rows)`, `displayTransactionResult(result)`, `displayIntentSummary(summary)`, `displayKeyValue(data, title?)` + +### Prompts (`src/cli/prompts/intent-prompts.ts`) + +All prompts use `inquirer`. Types used: `list`, `input`, `confirm`, `password`. + +| Prompt | Returns | +|--------|---------| +| `selectSourceChain` | `ChainConfig` | +| `selectDestinationChain` | `ChainConfig` | +| `selectToken` | `{ address, decimals, symbol? }` | +| `configureReward` | `{ token, amount: bigint, amountStr }` | +| `selectRecipient` | `UniversalAddress` | +| Intent confirmation | `boolean` (default `true`) | +| Destructive operations | `boolean` (default `false`) | + +### Error Handling (`src/utils/error-handler.ts`) + +**Error class hierarchy:** + +``` +RoutesCliError (code: ErrorCode, isUserError: boolean) — primary error type +CliError generic CLI errors +NetworkError ECONNREFUSED, ENOTFOUND, etc. +ValidationError input validation failures +ConfigurationError config issues +BlockchainError chain operation failures +``` + +**Retry wrapper:** +```typescript +withRetry(fn, maxRetries=3, delayMs=1000) +// Retries only: NetworkError, ECONNREFUSED, ETIMEDOUT, ENOTFOUND +// Backoff: delayMs × 1.5 per attempt +``` + +--- + +## 9. Security Architecture + +### KeyHandle — Private Key Zeroization + +```typescript +// src/core/security/key-manager.ts +class KeyHandle { + private buffer: Buffer; // mutable; can be zeroed + + use(fn: (key: string) => T): T { + try { return fn(this.buffer.toString('utf8')); } + finally { this.buffer.fill(0); } // always zeroed, even on throw + } +} +``` + +**Async limitation:** `use()` is synchronous. The buffer is zeroed immediately after `fn` returns — before any `await`. For async publisher flows, callers must derive all synchronous key material (account, address) inside `use()` and create a second `KeyHandle` for the async publisher: + +```typescript +// src/commands/publish.ts +const { senderNative, publishKeyHandle } = keyHandle.use(rawKey => ({ + senderNative: getWalletAddress(sourceChain.type, rawKey), + publishKeyHandle: new KeyHandle(rawKey), // second handle for publisher +})); +``` + +### Chain ID Allowlist + +All chain IDs from `CHAIN_CONFIGS` are registered at startup. `BasePublisher.runPreflightChecks()` verifies the source chain ID is allowlisted before any transaction is sent. Publishing to an unrecognized chain ID throws immediately. + +### TVM Key Clearing + +TronWeb requires the private key on the global instance object. `TVMPublisher` enforces a strict try-finally pattern: +```typescript +this.tronWeb.setPrivateKey(key); +try { /* all TronWeb operations */ } +finally { this.tronWeb.setPrivateKey(''); } // always cleared +``` + +### Address & Input Validation + +All user-supplied addresses are validated with Zod schemas before normalization. Invalid inputs throw `RoutesCliError.invalidAddress()` with a chain-specific format hint. + +--- + +## 10. Data & Control Flows + +### Address Flow + +``` +User types: "0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b" + │ normalize(addr, ChainType.EVM) + ▼ +Internal: "0x000000000000000000000000742d35cc6634c0532925a3b8d65c32c2b3f6de1b" + (UniversalAddress stored in Intent, passed between functions) + │ │ + │ at blockchain boundary │ at display boundary + ▼ ▼ + EVMPublisher.publish() logger.displayIntentSummary() + TVMPublisher.publish() status command output + SVMPublisher.publish() + PortalEncoder.encode() +``` + +### Publish Transaction Data Flow + +``` +CLI Prompts (chain names, token, amount, recipient) + │ + └─► IntentService.buildIntent() + │ + ├─► getQuote() → encodedRoute (hex), sourcePortal, proverAddress, deadline + │ (addresses returned in chain-native format; normalized before storing) + │ + └─► reward = { + deadline, prover (UniversalAddress), creator (UniversalAddress), + nativeAmount: 0n, tokens: [{ token: UniversalAddress, amount: bigint }] + } + │ + └─► publisher.publish(source, dest, reward, encodedRoute, keyHandle, portal) + │ + ├─ EVM: + │ denormalize addresses in reward + │ viem encodeFunctionData("publishAndFund", [...]) + │ walletClient.sendTransaction({ data, value }) + │ + ├─ TVM: + │ denormalize addresses in reward + │ tronWeb.contract.publishAndFund(...).send({ callValue }) + │ + └─ SVM: + setupAnchorProgram() + buildFundingTransaction() [vault PDA + ATAs] + sendAndConfirmTransaction() +``` + +--- + +## 11. Module Dependency Graph + +Layers are strictly one-directional. Lower layers must never import from higher layers. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Layer 4 — Commands (src/commands/, src/cli/, src/index.ts) │ +│ May import from: all layers below │ +└──────────────────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────────────────▼──────────────────────────────┐ +│ Layer 3 — Blockchain (src/blockchain/) │ +│ evm-publisher, tvm-publisher, svm-publisher │ +│ base-publisher, publisher-factory │ +│ evm/, tvm/, svm/ (client factories, chain handlers, PDAs) │ +│ May import from: core/, config/, commons/ │ +└──────────────────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────────────────▼──────────────────────────────┐ +│ Layer 2 — Config (src/config/) │ +│ chains.ts, tokens.ts, env.ts, config-service.ts │ +│ May import from: core/, commons/ │ +└──────────────────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────────────────▼──────────────────────────────┐ +│ Layer 1 — Core (src/core/) │ +│ interfaces/, types/, errors/, validation/ │ +│ utils/: address-normalizer, portal-encoder, intent-converter │ +│ quote, chain-detector │ +│ chain/: chain-handler.interface, chain-registry │ +│ security/: key-manager │ +│ services/: intent-service │ +│ May import from: commons/ only │ +└──────────────────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────────────────▼──────────────────────────────┐ +│ Layer 0 — Commons (src/commons/) │ +│ abis/, utils/error-handler, utils/serialize │ +│ types/portal-idl.* │ +│ No internal @/ imports │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Cross-cutting (importable from any layer):** +- `src/utils/logger.ts` — singleton logger +- `src/core/errors/` — `RoutesCliError`, `ErrorCode` + +--- + +## 12. Build System + +### TypeScript Configuration + +``` +target: ES2021 +module: CommonJS +outDir: ./dist +strict: true (all strict checks: noImplicitAny, strictNullChecks, noUnusedLocals, etc.) +paths: @/* → src/* +exclude: node_modules, dist, src/scripts, tests +``` + +### Scripts + +```bash +pnpm build # tsc +pnpm dev # tsx + tsconfig-paths (production chains) +pnpm dev:testnet # NODE_CHAINS_ENV=development tsx +pnpm start # node dist/index.js (production) +pnpm clean # rm -rf dist +pnpm test # jest (all) +pnpm test:unit # tests/core | config | blockchain +pnpm test:integration # tests/integration +pnpm test:coverage # jest --coverage +pnpm test:e2e # jest.e2e.config.ts +pnpm typecheck # tsc --noEmit +pnpm lint # eslint src tests +pnpm format # prettier +pnpm docs # typedoc → GitHub Pages +``` + +### Key Dependencies + +| Category | Package | Version | Purpose | +|----------|---------|---------|---------| +| EVM | `viem` | ^2.40.1 | EVM client, ABI encoding, address utils | +| Tron | `tronweb` | ^6.2.0 | Tron client + address conversion | +| Solana | `@solana/web3.js` | ^1.91.8 | Solana client | +| Solana | `@solana/spl-token` | ^0.4.14 | SPL token accounts | +| Solana | `@coral-xyz/anchor` | ^0.32.1 | Anchor IDL + program interaction | +| CLI | `commander` | ^12.1.0 | Argument parsing | +| CLI | `inquirer` | ^9.3.7 | Interactive prompts | +| CLI | `ora` | ^8.2.0 | Spinners | +| CLI | `chalk` | ^4.1.2 | Terminal colors | +| CLI | `cli-table3` | ^0.6.5 | Formatted tables | +| Validation | `zod` | ^4.3.6 | Schema validation | +| Config | `dotenv` | ^16.4.5 | `.env` loading | +| Dev | `tsx` | ^4.20.5 | TypeScript execution in dev | +| Dev | `jest` | ^30.2.0 | Test runner | +| Dev | `husky + lint-staged` | latest | Pre-commit hooks | +| Dev | `typedoc` | ^0.28.17 | API documentation | +| Dev | `@changesets/cli` | latest | Changelog management | + +--- + +## 13. Supported Chains & Tokens + +### Production Chains + +| Name | ID | Type | Portal | RPC Default | +|------|----|------|--------|-------------| +| Ethereum | 1 | EVM | — | viem default | +| Optimism | 10 | EVM | — | https://mainnet.optimism.io | +| BSC | 56 | EVM | — | viem default | +| Base | 8453 | EVM | `0x399Dbd5...` | https://mainnet.base.org | +| Arbitrum | 42161 | EVM | — | viem default | +| Polygon | 137 | EVM | — | viem default | +| Ronin | 2020 | EVM | — | viem default | +| Sonic | 146 | EVM | — | viem default | +| Hyper EVM | 999 | EVM | — | viem default | +| Tron | 728126428 | TVM | — | https://api.trongrid.io | +| Solana | 1399811149 | SVM | — | https://api.mainnet-beta.solana.com | + +### Development / Testnet Chains + +| Name | ID | Type | Portal | Prover | +|------|----|------|--------|--------| +| Base Sepolia | 84532 | EVM | `0x06EFdb68...` | `0x9523b6c0...` | +| Optimism Sepolia | 11155420 | EVM | `0x06EFdb68...` | `0x9523b6c0...` | +| Plasma Testnet | 9746 | EVM | `0x06EFdb68...` | `0x9523b6c0...` | +| Sepolia | 11155111 | EVM | `0x06EFdb68...` | `0x9523b6c0...` | +| Tron Shasta | 2494104990 | TVM | — | — | +| Solana Devnet | 1399811150 | SVM | — | — | + +### Configured Tokens + +| Symbol | Decimals | Chains | +|--------|----------|--------| +| USDC | 6 | ETH, OP, Base, Polygon, Arbitrum, HyperEVM, Ronin, Sonic, Base Sepolia, OP Sepolia, Plasma, Sepolia, Solana mainnet/devnet | +| USDT | 6 | ETH, OP, Base, Tron mainnet/shasta, HyperEVM, Solana mainnet | +| bUSDC | 18 | BSC | +| bUSDT | 18 | BSC | + +--- + +## 14. Known Issues & Improvement Opportunities + +Structural issues observed during architecture research. Intended as input for the improvement initiative. + +### 1. Import Order Side-Effect Dependency +**File:** `src/index.ts` +Chain handlers must be imported before config files due to `chainRegistry` side-effects at module load time. This is fragile and breaks silently if auto-sort tools reorder the imports. +**Opportunity:** Explicit `initializeChainHandlers()` function called before any config access. + +### 2. KeyHandle Async Limitation +**Files:** `src/core/security/key-manager.ts`, `src/commands/publish.ts` +`KeyHandle.use()` zeroes the buffer synchronously after `fn()` returns. For async publisher flows a second `KeyHandle` must be constructed from the raw key, partially defeating the zeroization guarantee. +**Opportunity:** An async-aware `KeyHandle` that defers zeroization until `dispose()` is explicitly called. + +### 3. Intent Persistence Not Implemented +CLAUDE.md and the existing ARCHITECTURE.md describe intent storage to `~/.routes-cli/intents.json` but this is not implemented. Refunds require manual record-keeping. +**Opportunity:** Implement the local intent store as described. + +### 4. IntentService Mixes Concerns +`src/core/services/intent-service.ts` combines quote fetching, UI prompts, reward construction, and route encoding in a single class. +**Opportunity:** Separate into `QuoteService` (pure data), `IntentBuilder` (pure construction), and `PublishOrchestrator` (UX + confirmation flow). + +### 5. No Standalone IntentBuilder +CLAUDE.md references `src/builders/intent-builder.ts` with a fluent builder pattern, but the file does not exist. All intent construction is coupled to CLI prompts. +**Opportunity:** Implement a prompt-free `IntentBuilder` to enable programmatic SDK usage. + +### 6. Missing Portal Addresses on Most Production Chains +Only Base (mainnet) and the four testnet chains have portal addresses configured. All other production chains depend entirely on the quote service to supply the portal address. +**Opportunity:** Populate portal and prover addresses for all chains where Eco Protocol is deployed. + +### 7. Status Command is EVM-Only +Intent status (`IntentFulfilled` event query) only works on EVM chains. +**Opportunity:** Add `checkStatus()` to `BasePublisher` (or a separate `StatusChecker` interface) for TVM and SVM. + +### 8. RPC Fallback is TVM/SVM-Only +TVM and SVM have configurable fallback RPC URLs; EVM has none. +**Opportunity:** Extend fallback RPC strategy uniformly to EVM chains. + +### 9. No Tests Despite Full Test Infrastructure +Jest, ts-jest, E2E config, and test scripts are all set up but no test files exist. Core utilities (AddressNormalizer, PortalEncoder, IntentService) are untested. +**Opportunity:** Start with unit tests for `AddressNormalizer` and `PortalEncoder`; add publisher integration tests using the factory mock pattern. + +### 10. Hardcoded dAppID in Quote Requests +`dAppID: 'eco-routes-cli'` is hardcoded in `quote.ts`. +**Opportunity:** Make configurable for SDK/programmatic usage. + +### 11. TVMPublisher Singleton TronWeb State +TronWeb holds private key state on a shared instance. Concurrent use of `TVMPublisher` would be unsafe. +**Opportunity:** Create TronWeb per-publish-call or add a concurrency guard. + +### 12. Manual Route Deadline is Hardcoded +The quote fallback path sets route deadline to `now + 2 hours` with no way to configure it. +**Opportunity:** Expose as a CLI flag or configurable default. + +--- + +## 15. Quick Reference: Adding a New Chain + +Complete checklist for adding `ChainType.XVM` support: + +``` +1. src/core/interfaces/intent.ts + Add: XVM = 'XVM' to ChainType enum + +2. src/blockchain/xvm/xvm-chain-handler.ts (new file) + Implement ChainHandler interface + Bottom: chainRegistry.register(new XvmChainHandler()) + +3. src/core/utils/address-normalizer.ts (optional helpers) + Add: static normalizeXvm(addr: XvmAddress): UniversalAddress + Add: static denormalizeToXvm(addr: UniversalAddress): XvmAddress + +4. src/blockchain/xvm/xvm-client-factory.ts (new file) + Define XvmClientFactory interface + Export: DefaultXvmClientFactory + +5. src/blockchain/xvm-publisher.ts (new file) + Extend BasePublisher + Implement publish(), getBalance(), validate() — all with `override` keyword + Denormalize addresses inside publish() before any RPC call + +6. src/blockchain/publisher-factory.ts + Add: case ChainType.XVM: return new XvmPublisher(rpcUrl, options?.xvmClientFactory) + +7. src/config/chains.ts + Add XVM chain configs with portalAddress in UniversalAddress format + +8. src/config/tokens.ts + Add token addresses for XVM chains (in UniversalAddress format) + +9. src/index.ts (BEFORE all other @/ imports!) + Add: import '@/blockchain/xvm/xvm-chain-handler' + +10. tests/__mocks__/xvm-client-factory.mock.ts + Create mock factory for integration tests +``` + +After step 9, `AddressNormalizer.normalize(addr, ChainType.XVM)` and +`AddressNormalizer.denormalize(addr, ChainType.XVM)` work automatically everywhere. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4e823d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and uses [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. The changelog is managed with [Changesets](https://github.com/changesets/changesets). + +--- + +## 1.0.0 (initial release) + +### Features + +- **Multi-chain intent publishing** — publish cross-chain intents on EVM, TVM (Tron), and SVM (Solana) with a single unified CLI. +- **Universal Address System** — 32-byte chain-agnostic address format enabling consistent cross-chain address handling internally; chain-native formats displayed to users. +- **Interactive publishing flow** — guided prompts for chain selection, token configuration, quote fetching, and deadline calculation. +- **Multi-format private key support** — EVM (0x-prefixed hex), TVM (hex without 0x), SVM (base58, byte array, or comma-separated). +- **Quote integration** — real-time route quotes for optimal intent pricing and path finding. +- **Intent refund system** — locally tracked intents (via `~/.routes-cli/intents.json`) with refund eligibility checking. +- **Rich CLI experience** — colored output, progress spinners (ora), formatted tables (cli-table3), and interactive prompts (inquirer). + +### Supported Chains + +- Ethereum Mainnet (EVM) +- Base Mainnet (EVM) +- Optimism (EVM) +- Arbitrum One (EVM) +- Tron Mainnet (TVM) +- Solana Mainnet (SVM) + +### Architecture Highlights + +- Publisher abstraction (`BasePublisher`) with concrete implementations for EVM, TVM, and SVM. +- Typed error hierarchy (`RoutesCliError`) with machine-readable error codes. +- Runtime environment validation with Zod. +- Dependency injection in all publisher classes for testability. +- Chain plugin registry for self-registering chain handlers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f6dc79d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,268 @@ +# Contributing to Routes CLI + +Thank you for your interest in contributing to Routes CLI! This guide will help you get started. + +--- + +## 1. Development Setup + +### Prerequisites + +- **Node.js** >= 18.0.0 +- **pnpm** (preferred package manager) +- Git + +### Clone and install + +```bash +git clone https://github.com/eco/routes-cli.git +cd routes-cli +pnpm install +``` + +### Environment configuration + +Copy the example environment file and fill in your private keys: + +```bash +cp .env.example .env +``` + +Required variables in `.env`: + +``` +EVM_PRIVATE_KEY=0x<64-hex-chars> # EVM chains (Ethereum, Base, Optimism, …) +TVM_PRIVATE_KEY=<64-hex-chars> # Tron (no 0x prefix) +SVM_PRIVATE_KEY= # Solana (base58, byte array, or comma-separated) +``` + +> **Security note:** Never commit your `.env` file. It is already listed in `.gitignore`. +> Use dedicated test accounts with minimal funds — never your main wallet. + +### Run in development mode + +```bash +pnpm dev publish # Interactive intent publishing +pnpm dev chains # List supported chains +pnpm dev tokens # List configured tokens +``` + +### Build + +```bash +pnpm build # Compiles TypeScript to dist/ +pnpm clean # Removes dist/ +``` + +--- + +## 2. Branch Naming Convention + +| Prefix | When to use | +|--------|-------------| +| `feat/` | New feature or capability | +| `fix/` | Bug fix | +| `docs/` | Documentation only changes | +| `refactor/` | Code restructuring with no behaviour change | +| `test/` | Adding or improving tests | + +**Examples:** + +``` +feat/add-polygon-chain +fix/tvm-token-approval-loop +docs/update-contributing-guide +refactor/extract-quote-service +test/evm-publisher-unit-tests +``` + +Branch names should be lowercase and use hyphens, not underscores or spaces. + +--- + +## 3. Commit Message Format + +Routes CLI follows the [Conventional Commits](https://www.conventionalcommits.org/) specification. + +``` +(): + +[optional body — explain the *why*, not the *what*] +``` + +### Types + +| Type | Use for | +|------|---------| +| `feat` | A new feature | +| `fix` | A bug fix | +| `chore` | Build scripts, dependency updates, tooling | +| `docs` | Documentation changes only | +| `refactor` | Restructuring code without changing behaviour | +| `test` | Adding or fixing tests | +| `perf` | Performance improvements | + +### Scope (optional) + +Use the affected module or file area: `evm-publisher`, `config`, `svm`, `cli`, `address-normalizer`, etc. + +### Examples + +``` +feat(config): add Polygon chain support +fix(tvm-publisher): loop over all reward tokens for approval +docs(readme): add Solana private key format examples +test(address-normalizer): add round-trip tests for all chain types +chore(deps): bump viem to 2.x +``` + +--- + +## 4. Pull Request Checklist + +Before opening a PR, verify all of the following: + +- [ ] **Tests pass** — `pnpm test` exits with zero errors +- [ ] **TypeScript compiles** — `pnpm build` succeeds with no type errors +- [ ] **Lint passes** — `pnpm lint` reports zero errors +- [ ] **No regressions** — existing tests are not deleted or weakened +- [ ] **Docs updated** — README, ARCHITECTURE.md, or inline JSDoc updated where relevant +- [ ] **Commit messages** follow Conventional Commits format +- [ ] **`.env` not committed** — double-check `git status` before pushing + +For new features, also ensure: + +- [ ] A test is added that would fail without the change +- [ ] Edge cases and error paths are covered +- [ ] The new chain/token/feature is documented in relevant config files + +--- + +## 5. Testing Guide + +### Running tests + +```bash +pnpm test # Run all unit + integration tests +pnpm test --watch # Watch mode for active development +pnpm test # Run tests matching a file/name pattern +pnpm test:e2e # Run E2E tests (requires Docker and BASE_RPC_URL) +``` + +### Test structure + +``` +tests/ +├── unit/ # Pure unit tests (no I/O) +│ ├── address-normalizer.test.ts +│ ├── chain-detector.test.ts +│ ├── intent-converter.test.ts +│ └── portal-encoder.test.ts +├── blockchain/ # Publisher tests with mocked clients +│ └── evm-publisher.integration.test.ts +├── integration/ # Full-pipeline integration tests +│ └── intent-publishing.test.ts +├── config/ # Config loading integration tests +│ ├── chains.test.ts +│ └── tokens.test.ts +├── e2e/ # End-to-end tests against Anvil fork +│ └── evm-publish.e2e.test.ts +└── __mocks__/ # Shared mock factories +``` + +### Writing new tests + +1. **Unit tests** go in `tests/unit/` — mock all I/O, test one function at a time. +2. **Integration tests** go in `tests/integration/` or `tests/blockchain/` — use injected mock clients via the factory pattern (see `PublisherFactory` and `tests/__mocks__/`). +3. **E2E tests** go in `tests/e2e/` — use a real Anvil fork; add your test to the existing file or create a new `*.e2e.test.ts`. + +**Key conventions:** + +- Use `beforeEach(() => jest.clearAllMocks())` to isolate per-test mock state. +- Prefer `mockResolvedValueOnce` over `mockResolvedValue` to catch unexpected extra calls. +- Use `expect.objectContaining(...)` for partial assertions on large objects. +- Fixtures should use real, well-known addresses (e.g. vitalik.eth, USDC contract) rather than made-up values. +- Universal Addresses (32-byte `0x` + 64-hex) must be used in all test fixtures except where testing chain-native formats. + +### Test configuration files + +- `jest.config.ts` — unit + integration tests (excludes `tests/e2e/`) +- `jest.e2e.config.ts` — E2E tests only (no viem mock, longer timeout, single worker) + +--- + +## 6. Code Review Process + +### Submitting a PR + +1. Open a PR against the `main` branch. +2. Fill in the PR description: what changed, why, and how to test it. +3. Link any related issues. +4. Ensure all CI checks pass before requesting review. + +### Review timeline + +- Initial review within **2 business days** for small PRs (< 200 lines changed). +- Larger or architectural PRs may take longer; consider splitting into smaller PRs. +- If you have not received a review after 3 business days, ping the maintainer on the PR. + +### What reviewers look for + +- Correctness and edge-case coverage. +- Adherence to the Universal Address pattern (normalize on input, denormalize only at boundaries). +- No global state mutation (see `ConfigService` pattern in `src/config/config-service.ts`). +- Publisher classes receive Universal Addresses and denormalize internally before blockchain calls. +- New chains/tokens follow existing patterns in `src/config/chains.ts` and `src/config/tokens.ts`. +- Tests that actually exercise the new or fixed code path. + +### Addressing review feedback + +- Push new commits to the same branch; do not force-push unless explicitly asked. +- Mark conversations as resolved after addressing them. +- If you disagree with feedback, explain your reasoning — discussion is welcome. + +--- + +## 7. Release Process + +Routes CLI uses [Changesets](https://github.com/changesets/changesets) to manage versioning and `CHANGELOG.md` updates. + +### For contributors — describe your change + +When your PR includes a user-facing change, add a changeset file: + +```bash +pnpm changeset +``` + +The CLI will prompt you to: +1. Select the bump type — `major` (breaking), `minor` (new feature), or `patch` (bug fix). +2. Write a short summary of the change for the CHANGELOG. + +This creates a `.changeset/.md` file. **Commit this file with your PR.** + +> **When to skip changesets:** Pure documentation, test, or CI changes that have no impact on +> CLI behaviour or the published package do not need a changeset file. + +### For maintainers — cutting a release + +1. Merge all PRs for the release into `main`. Each PR should include its `.changeset/*.md` file. +2. Run the changeset version command to consume the changeset files and bump `package.json`: + ```bash + pnpm changeset version + ``` + This updates `package.json`, aggregates all changeset summaries into `CHANGELOG.md`, and removes the consumed `.changeset/*.md` files. +3. Review the diff — confirm `package.json` version and `CHANGELOG.md` look correct. +4. Commit and push: + ```bash + git add package.json CHANGELOG.md pnpm-lock.yaml + git commit -m "chore(release): v$(node -p 'require(\"./package.json\").version')" + git push origin main + ``` +5. Tag the release — CI triggers the `release` job on tag push: + ```bash + VERSION=$(node -p 'require("./package.json").version') + git tag "v$VERSION" + git push origin "v$VERSION" + ``` +6. CI will publish to npm (`NPM_TOKEN` secret required) and create a GitHub Release automatically. diff --git a/README.md b/README.md index 92806ed..d18fe5b 100644 --- a/README.md +++ b/README.md @@ -1,320 +1,172 @@ -# Routes CLI - Intent Publisher - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](https://nodejs.org/) - -A powerful command-line interface for creating and publishing cross-chain intents on EVM, TVM (Tron), and SVM (Solana) blockchains. Built by Eco Protocol for seamless multi-chain interactions. - -## 🌟 Key Features - -- **🌍 Multi-chain Support**: Seamlessly publish intents across EVM, Tron (TVM), and Solana (SVM) chains -- **💸 Quote Integration**: Real-time route quotes for optimal pricing and path finding -- **🎯 Interactive Wizards**: Intuitive step-by-step guides for intent creation and publishing -- **🔐 Secure Key Management**: Environment-based private key storage with multi-format support -- **📊 Rich CLI Experience**: Beautiful tables, spinners, and colored output for better UX -- **⚡ Smart Defaults**: Automatic deadline calculation and intelligent configuration -- **🔌 Extensible Architecture**: Easy integration of new chains and tokens -- **📦 Standalone Operation**: Zero external service dependencies - -## 📋 Prerequisites - -- Node.js >= 18.0.0 -- pnpm (recommended) or npm -- Private keys for the chains you want to use - -## 📦 Installation - -### Clone and Build +

+ Eco Protocol +

-```bash -git clone https://github.com/eco/routes-cli.git -cd routes-cli -pnpm install -pnpm build -``` +

Routes CLI

+

Send assets across chains with a single command.

-### Global Installation (Optional) +

+ License: MIT + Node.js Version +

-```bash -pnpm link -# Now you can use 'eco-routes-cli' globally -``` +Routes CLI is a terminal tool for publishing **cross-chain intents** built on the [Eco Routes Protocol](https://github.com/eco/eco-routes/). You lock a reward on your source chain; competitive solvers race to deliver the result on your destination chain. Supports EVM (Ethereum, Base, Optimism, and more), and Solana. -## Configuration +--- -1. Copy the example environment file: -```bash -cp .env.example .env -``` +## See It In Action -2. Add your private keys to `.env`: -```env -# Private keys for each chain type -EVM_PRIVATE_KEY=0x... -TVM_PRIVATE_KEY=... -SVM_PRIVATE_KEY=... - -# Optional: Custom RPC endpoints -EVM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/... -TVM_RPC_URL=https://api.trongrid.io -SVM_RPC_URL=https://api.mainnet-beta.solana.com - -# Optional: Portal contract addresses -PORTAL_ADDRESS_ETH=0x... -PORTAL_ADDRESS_OPTIMISM=0x... -PORTAL_ADDRESS_TRON=T... -PORTAL_ADDRESS_SOLANA=... ``` +$ eco-routes-cli publish --private-key 0xYOUR_PRIVATE_KEY -## 🚀 Quick Start +? Select source chain: Base +? Select destination chain: Optimism -1. **Set up your environment:** - ```bash - cp .env.example .env - # Edit .env with your private keys - ``` +? Route token: USDC +? Route amount: 100 -2. **Publish your first intent:** - ```bash - pnpm dev publish - # Follow the interactive prompts - ``` +? Reward token: USDC +? Reward amount: 101 -3. **View available chains:** - ```bash - pnpm dev chains - ``` +┌─────────────────────────────────────────────┐ +│ Intent Summary │ +├──────────────────┬──────────────────────────┤ +│ Source chain │ Base (8453) │ +│ Destination │ Optimism (10) │ +│ Route │ 100 USDC → Optimism │ +│ Reward │ 101 USDC on Base │ +│ Route deadline │ 2026-02-26 14:00 UTC │ +│ Reward deadline │ 2026-02-26 15:00 UTC │ +└──────────────────┴──────────────────────────┘ -## 📖 Usage Guide +? Confirm and publish intent? Yes -### 🎯 Interactive Publishing (Recommended) - -The simplest and most user-friendly way to publish intents: - -```bash -pnpm dev publish +⠋ Publishing intent... +✔ Intent published — tx: 0xabc123...def456 ``` -#### Publishing Flow - -1. **🔗 Chain Selection** - - Select source chain (where rewards come from) - - Select destination chain (where route executes) - - Automatic quote fetching for optimal routing - -2. **💰 Token Configuration** - - **Route Token**: Choose destination chain token (native or ERC20/TRC20/SPL) - - **Route Amount**: Specify amount to transfer on destination - - **Reward Token**: Choose source chain token for prover reward - - **Reward Amount**: Specify reward amount for proof submission - -3. **⚙️ Automatic Configuration** - - Creator address derived from your wallet - - Prover address from chain configuration - - Portal address from destination chain - - Smart deadline calculation: - - Route deadline: 2 hours from now - - Reward deadline: 3 hours from now - -4. **✅ Review & Confirm** - - Display complete intent details - - Show estimated gas costs - - Confirm before blockchain submission +--- -### 🔄 Semi-Interactive Publishing +## Quick Start -Specify chains via command line, configure tokens interactively: +**1. Install globally** ```bash -# Mainnet examples -pnpm dev publish --source ethereum --destination optimism -pnpm dev publish --source tron --destination base -pnpm dev publish --source solana --destination ethereum - -# Testnet examples -pnpm dev publish --source base-sepolia --destination optimism-sepolia -pnpm dev publish --source tron-shasta --destination base-sepolia +npm i -g eco-routes-cli ``` -### ⚙️ Command Options +**2. Publish your first EVM intent** -| Option | Alias | Description | Example | -|--------|-------|-------------|----------| -| `--source` | `-s` | Source chain name or ID | `ethereum`, `1` | -| `--destination` | `-d` | Destination chain name or ID | `optimism`, `10` | -| `--verbose` | `-v` | Show detailed output | | - -### 📊 Information Commands - -#### List Supported Chains ```bash -pnpm dev chains -# Output: Formatted table with chain names, IDs, types, and native currencies +eco-routes-cli publish --private-key 0xYOUR_PRIVATE_KEY ``` -#### List Configured Tokens -```bash -pnpm dev tokens -# Output: Table showing token symbols, names, decimals, and chain availability -``` +Follow the prompts to select source chain (e.g. Base), destination chain (e.g. Optimism), token, and amounts. Done. -## Intent Structure - -An intent consists of two main parts: - -### Route -- `salt`: Random 32-byte hex value -- `deadline`: Unix timestamp for route expiration -- `portal`: Portal contract address on destination chain -- `nativeAmount`: Native token amount to transfer -- `tokens`: Array of token transfers -- `calls`: Array of contract calls to execute - -### Reward -- `deadline`: Unix timestamp for reward claim deadline -- `creator`: Address that created the intent -- `prover`: Prover contract address -- `nativeAmount`: Native token reward amount -- `tokens`: Array of token rewards - -## 🔧 Customization & Extension - -## Adding New Tokens - -Edit `src/config/tokens.ts`: - -```typescript -export const TOKEN_CONFIGS: Record = { - // ... existing tokens - - MYTOKEN: { - symbol: 'MTK', - name: 'My Token', - decimals: 18, - addresses: { - ethereum: AddressNormalizer.normalize('0x...', ChainType.EVM), - optimism: AddressNormalizer.normalize('0x...', ChainType.EVM), - }, - }, -}; -``` +> Tip: pass `--source` and `--destination` to skip chain selection prompts. -## 🛠️ Development +Prefer not to pass keys inline? Copy `.env.example` to `.env` and set `EVM_PRIVATE_KEY` (or `SVM_PRIVATE_KEY` for Solana) — the CLI will pick it up automatically. -### Available Scripts +--- -| Script | Description | -|--------|-------------| -| `pnpm build` | Compile TypeScript to JavaScript | -| `pnpm dev ` | Run in development mode with ts-node | -| `pnpm start ` | Run compiled version | -| `pnpm clean` | Remove build artifacts | +## What Is an Intent? -### Project Structure +An intent describes **what you want to happen on the destination chain** and **what reward you're offering** to whoever makes it happen. Solvers — independent actors monitoring the protocol — race to fulfill your intent and claim the reward. The faster the solver, the better the deal for everyone. -``` -routes-cli/ -├── src/ -│ ├── blockchain/ # Chain-specific implementations -│ ├── builders/ # Intent builder patterns -│ ├── commands/ # CLI command implementations -│ ├── config/ # Chain and token configurations -│ ├── core/ # Core types and utilities -│ ├── scripts/ # Standalone scripts -│ └── utils/ # Helper utilities -├── dist/ # Compiled output -├── .env.example # Environment template -└── package.json # Project dependencies -``` +This is powered by the [Eco Routes Protocol](https://github.com/eco/eco-routes/). For the full picture of how intents are encoded, published, and proven, see [ARCHITECTURE.md](./ARCHITECTURE.md). -## 🏗️ Architecture +--- -### Core Concepts +## Command Reference + +| Command | Description | +|---------|-------------| +| `eco-routes-cli publish` | Interactive intent publishing wizard | +| `eco-routes-cli publish --source --destination ` | Skip chain selection prompts | +| `eco-routes-cli chains` | List all supported chains | +| `eco-routes-cli tokens` | List all configured tokens | + +**`publish` flags:** + +| Flag | Alias | Description | +|------|-------|-------------| +| `--source` | `-s` | Source chain name or ID | +| `--destination` | `-d` | Destination chain name or ID | +| `--private-key` | `-k` | EVM private key (overrides `EVM_PRIVATE_KEY` env) | +| `--private-key-svm` | | SVM private key (overrides `SVM_PRIVATE_KEY` env) | +| `--recipient` | | Recipient address on the destination chain | +| `--portal-address` | | Portal contract address on the source chain | +| `--prover-address` | | Prover contract address on the source chain | +| `--rpc` | `-r` | RPC URL override for the source chain | +| `--dry-run` | | Validate and preview without broadcasting | +| `--watch` | `-w` | Watch for fulfillment after publishing | + +**Private key formats:** + +| Chain | Format | Example | +|-------|--------|---------| +| EVM | `0x` + 64 hex chars | `0xac09...ff80` | +| Tron | 64 hex chars, no `0x` | `ac09...ff80` | +| Solana | Base58 | `5Jd7F...` | +| Solana | JSON byte array | `[1,2,3,...]` | +| Solana | Comma-separated bytes | `1,2,3,...` | -- **UniversalAddress**: Chain-agnostic 32-byte address representation enabling cross-chain compatibility -- **PortalEncoder**: Specialized encoder for intent data across different blockchain types -- **AddressNormalizer**: Bidirectional converter between chain-native and universal address formats -- **IntentBuilder**: Fluent builder pattern for constructing complex intents programmatically -- **ChainTypeDetector**: Automatic chain type detection from configuration -- **Quote System**: Integration with routing protocols for optimal path finding +--- -### Design Principles +## Configuration Reference -1. **Chain Abstraction**: Uniform interface across different blockchain types -2. **Type Safety**: Full TypeScript support with strict typing -3. **Modularity**: Pluggable architecture for easy extension -4. **User Experience**: Interactive wizards with rich CLI feedback +Copy `.env.example` to `.env`. All variables except the private keys are optional. -## 🔑 Private Key Formats +| Variable | Required | Description | +|----------|----------|-------------| +| `EVM_PRIVATE_KEY` | For EVM chains | EVM wallet private key (`0x…`) | +| `SVM_PRIVATE_KEY` | For Solana | Solana wallet key (base58, array, or bytes) | +| `EVM_RPC_URL` | No | Override RPC for all EVM chains | +| `SVM_RPC_URL` | No | Override Solana RPC (default: mainnet-beta) | +| `SOLVER_URL` | No | Use a custom solver endpoint for quotes | +| `QUOTES_PREPROD` | No | Force preprod quote service (set to `true`) | +| `PORTAL_ADDRESS_ETH` | No | Override Ethereum portal contract | +| `PORTAL_ADDRESS_BASE` | No | Override Base portal contract | +| `PORTAL_ADDRESS_OPTIMISM` | No | Override Optimism portal contract | +| `PORTAL_ADDRESS_SOLANA` | No | Override Solana portal contract | -| Chain Type | Format | Example | -|------------|--------|----------| -| **EVM** | Hex with prefix | `0x1234...` (64 hex chars) | -| **Tron** | Hex without prefix | `1234...` (64 hex chars) | -| **Solana** | Base58 | `5Jd7F...` | -| | Byte array | `[1,2,3,...]` | -| | Comma-separated | `1,2,3,...` | +See `.env.example` for the complete list of portal address overrides. -## 🚨 Troubleshooting +--- -### Common Issues +## Troubleshooting -| Issue | Solution | -|-------|----------| -| `Invalid address format` | Check address matches chain type requirements | -| `Insufficient balance` | Ensure wallet has enough tokens and gas | -| `Chain not found` | Verify chain name/ID in supported list | -| `RPC timeout` | Check network connection or use custom RPC | -| `Private key error` | Verify key format matches chain type | -| `Quote unavailable` | Check source/destination pair compatibility | +| Issue | Fix | +|-------|-----| +| `Private key error` | Check the format matches the chain type — see Private key formats above | +| `Chain not found` | Run `eco-routes-cli chains` to verify the exact chain name or ID | +| `Insufficient balance` | Ensure your wallet has the reward token plus gas on the source chain | +| `Quote unavailable` | Not all chain pairs have live routes yet — try a different pair | +| `RPC timeout` | Set a custom RPC endpoint via `EVM_RPC_URL` / `TVM_RPC_URL` / `SVM_RPC_URL` | -### Debug Mode +Enable verbose output for more detail: ```bash -# Enable verbose logging -export DEBUG=eco-routes-cli:* -pnpm dev publish --verbose +DEBUG=* eco-routes-cli publish ``` -## 🔒 Security Best Practices - -1. **Never commit `.env` files** - Add to `.gitignore` -2. **Use environment variables** - Don't hardcode private keys -3. **Hardware wallets recommended** - For production environments -4. **Validate before publishing** - Use `--dry-run` flag -5. **Audit intent details** - Review all parameters before confirmation -6. **Secure RPC endpoints** - Use authenticated endpoints when possible -7. **Rotate keys regularly** - Especially for automated systems - -## 🤝 Contributing - -Contributions are welcome! Please follow these steps: - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'feat: add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -## 📄 License +--- -This project is licensed under the MIT License. +## Going Deeper -## 🙏 Acknowledgments +- [ARCHITECTURE.md](./ARCHITECTURE.md) — How intents work, the Universal Address system, publisher internals +- [CONTRIBUTING.md](./CONTRIBUTING.md) — Dev setup, adding chains and tokens, PR process +- [GitHub Issues](https://github.com/eco/routes-cli/issues) — Bug reports and feature requests -- Built with [Viem](https://viem.sh/) for EVM interactions -- [TronWeb](https://tronweb.network/) for Tron support -- [Solana Web3.js](https://solana-labs.github.io/solana-web3.js/) for Solana integration -- [Commander.js](https://github.com/tj/commander.js/) for CLI framework -- [Inquirer.js](https://github.com/SBoudrias/Inquirer.js/) for interactive prompts +--- -## 📞 Support +## License -- **Documentation**: [Full API Reference](https://docs.eco.org/routes-cli) -- **Issues**: [GitHub Issues](https://github.com/eco-protocol/routes-cli/issues) +MIT © [Eco Protocol](https://eco.com) ---- +Built with [viem](https://viem.sh/), and [@solana/web3.js](https://solana-labs.github.io/solana-web3.js/). +Powered by the [Eco Routes Protocol](https://github.com/eco/eco-routes/).

- Made with ❤️ by Eco Protocol -

\ No newline at end of file + Eco Protocol +

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..207f126 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,204 @@ +# Security Policy + +## Supported Versions + +The following versions of eco-routes-cli receive security patches: + +| Version | Supported | +|---------|--------------------| +| 1.x | ✅ Active support | +| < 1.0 | ❌ End of life | + +Security fixes are released as patch versions (e.g. `1.0.1`) and announced via +[GitHub Releases](https://github.com/eco-protocol/routes-cli/releases). + +--- + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for security vulnerabilities.** + +### Option A — GitHub Security Advisory (preferred) + +Use GitHub's private disclosure flow: + +1. Go to the repository on GitHub +2. Click **Security** → **Advisories** → **Report a vulnerability** +3. Fill in the vulnerability details (description, affected versions, reproduction steps) +4. Submit — the maintainers will respond within **5 business days** + +### Option B — Email + +Send a report to the Eco Protocol security team: + +``` +security@eco.org +``` + +Include: +- A description of the vulnerability +- Steps to reproduce +- Potential impact assessment +- Any suggested mitigations + +You will receive an acknowledgement within **2 business days**. + +### What to expect + +- We will confirm receipt and begin investigation within 5 business days +- We aim to release a patch within 30 days for critical vulnerabilities +- You will be credited in the release notes unless you prefer otherwise +- We do not offer a bug bounty program at this time + +--- + +## Security Model + +### Private Key Handling + +eco-routes-cli requires private keys to sign blockchain transactions. Here is exactly how +keys are handled at each stage: + +1. **Load** — Private keys are read from environment variables (`EVM_PRIVATE_KEY`, etc.) + into process memory when the CLI starts. They are never written to disk by the application. + +2. **Pass** — The key string is passed as a function argument to the relevant publisher + (`EvmPublisher`, `TvmPublisher`, or `SvmPublisher`). It is not stored in any global or + class-level field between calls. + +3. **Sign** — The key is handed to the chain-specific library (`viem`, `TronWeb`, or + `@solana/web3.js`) to sign the transaction. For TVM, the key is loaded into the `TronWeb` + instance immediately before signing and cleared from the instance immediately after (via a + `finally` block calling `this.tronWeb.setPrivateKey('')`). + +4. **Discard** — After signing, the function scope ends and the string is eligible for garbage + collection. No copy is retained. + +**Important limitations:** JavaScript strings are immutable — the runtime may retain a copy in +memory until the garbage collector runs. For high-security deployments, run the CLI in a +dedicated process and terminate it immediately after use. + +### What is never persisted + +- Private keys are **never** written to `~/.routes-cli/` or any other disk location +- The local intent store (`~/.routes-cli/intents.json`) records intent metadata only + (hashes, chain IDs, reward amounts) — never private keys or wallet addresses derived from them +- Log output never includes private key material + +### RPC Endpoints + +The CLI connects to RPC endpoints to submit transactions. By default: + +- **EVM**: Public RPC (configurable via `EVM_RPC_URL`) +- **TVM**: `https://api.trongrid.io` (configurable via `TVM_RPC_URL`) +- **SVM**: `https://api.mainnet-beta.solana.com` (configurable via `SVM_RPC_URL`) + +Use a private RPC endpoint (`EVM_RPC_URL`, etc.) if you are concerned about transaction +metadata leaking to public node operators. + +--- + +## Private Key Format Reference + +### EVM Chains (Ethereum, Optimism, Base, Arbitrum, etc.) + +``` +Format: 0x followed by exactly 64 hexadecimal characters +Example: 0xabc123...def456 (0x + 64 hex chars = 66 chars total) +Regex: ^0x[a-fA-F0-9]{64}$ +``` + +Set in `.env`: +```bash +EVM_PRIVATE_KEY=0x +``` + +### TVM (Tron) + +``` +Format: Exactly 64 hexadecimal characters — NO 0x prefix +Example: abc123...def456 (64 hex chars, no prefix) +Regex: ^[a-fA-F0-9]{64}$ +``` + +Set in `.env`: +```bash +TVM_PRIVATE_KEY= +``` + +Note: This is the raw private key, not the WIF-encoded format used by some Tron wallets. + +### SVM (Solana) + +Solana private keys can be provided in any of three formats: + +**Base58 encoded keypair** (58 characters, standard export from Phantom / Solflare): +```bash +SVM_PRIVATE_KEY=5Kb8kLf9zgWQnogidDA76MzPL6TsZZY36hWXMssSzNydYXYB... +``` + +**Byte array** (JSON array of 64 numbers, standard format from `solana-keygen`): +```bash +SVM_PRIVATE_KEY=[12,34,56,...,255] # 64 comma-separated numbers inside brackets +``` + +**Comma-separated bytes** (same as array, without brackets): +```bash +SVM_PRIVATE_KEY=12,34,56,...,255 # 64 comma-separated numbers +``` + +--- + +## Best Practices for Users + +### Use dedicated keys + +Never use a personal wallet key with this CLI. Create a dedicated wallet that holds only +the tokens needed for publishing: + +```bash +# EVM: create a fresh key with cast +cast wallet new + +# Solana: create a fresh keypair +solana-keygen new --outfile ~/.config/solana/routes-cli.json +``` + +### Keep .env out of version control + +Confirm your `.env` file is ignored before committing: + +```bash +git check-ignore -v .env # should print: .gitignore:N:.env +``` + +If it is not ignored, add it: +```bash +echo '.env' >> .gitignore +``` + +### Use a hardware wallet for large amounts + +For production use with significant token amounts, consider a hardware wallet integration. +The CLI currently accepts software keys only — hardware wallet support is on the roadmap. + +### Rotate keys after any suspected exposure + +If you believe a key has been exposed (e.g., accidentally committed, shown in a log): + +1. Move all tokens off the compromised wallet immediately +2. Generate a new key +3. Update your `.env` with the new key +4. If the key was in git history, follow the instructions in [TASK-001](IMPROVEMENT_PLAN.md) + to rewrite history and notify collaborators to re-clone + +### Keep dependencies up to date + +Run `pnpm audit` regularly to check for known vulnerabilities in dependencies: + +```bash +pnpm audit --audit-level=high +``` + +The CI pipeline runs `pnpm audit` on every push to `main` and on a daily schedule via +`.github/workflows/security.yml`. diff --git a/docs/plans/2026-02-20-architecture-improvement-design.md b/docs/plans/2026-02-20-architecture-improvement-design.md new file mode 100644 index 0000000..950e1d1 --- /dev/null +++ b/docs/plans/2026-02-20-architecture-improvement-design.md @@ -0,0 +1,462 @@ +# Architecture Improvement Design + +**Date:** 2026-02-20 +**Scope:** Transformative redesign — all layers, CLI only (no SDK surface) +**Approach:** Service Container via NestJS standalone + `nestjs-commander` + +--- + +## Background + +The current architecture was researched and documented in `ARCHITECTURE.md`. It identified 12 structural issues across four dimensions: + +- **Correctness / reliability** — missing intent persistence, EVM-only status, missing portal addresses on most production chains +- **Developer experience** — side-effect import initialization, monolithic `IntentService`, missing `IntentBuilder`, hardcoded values +- **Security** — `KeyHandle` async race condition, TronWeb singleton state, no EVM RPC fallback +- **Extensibility** — chain type logic scattered across directories, no clean boundary for adding new chains + +This design resolves all 12 issues through a transformative restructure using NestJS as the dependency injection container. + +--- + +## Decisions + +| Question | Decision | +|----------|----------| +| Scope | Transformative — clean break, not incremental | +| Output | CLI only — no public SDK surface | +| DI Framework | NestJS standalone (`createApplicationContext`) + `nestjs-commander` | +| Prompt library | Keep `inquirer` — wrapped in injectable `PromptService` | +| Test coverage | Not a deliverable of this effort — design for testability, write tests later | +| Phasing | Comprehensive — single design spec, implemented as one effort | + +--- + +## Module Structure + +The application decomposes into focused NestJS modules. `CoreModule` was deliberately excluded — types, errors, and security utilities are plain TypeScript in `shared/` and imported directly. + +``` +AppModule + imports: [ConfigModule, BlockchainModule, IntentModule, + QuoteModule, StatusModule, CliModule] + +ConfigModule (global) — env validation, typed config access, token definitions +BlockchainModule (global) — everything chain-related: registry, handlers, publishers, + encoding, address normalization, chains config, RPC fallback +IntentModule — IntentBuilder (pure), IntentStorage (persistence) +QuoteModule — QuoteService (network, no I/O side effects) +StatusModule — StatusService (all chain types) +CliModule (leaf) — commands, PromptService, DisplayService +``` + +**`ConfigModule` and `BlockchainModule` are `@Global()`** — available everywhere without explicit import in every module. + +**`CliModule` is the only leaf** — it imports everything but nothing imports it. CLI concerns can never leak into business logic. + +--- + +## Directory Structure + +``` +src/ +├── main.ts # bootstrap() only +├── app.module.ts # root AppModule +│ +├── shared/ # plain TypeScript — no NestJS module +│ ├── types/ +│ │ ├── intent.interface.ts # Intent, Route, Reward interfaces +│ │ ├── universal-address.ts # UniversalAddress branded type + helpers +│ │ └── blockchain-addresses.ts # EvmAddress, TronAddress, SvmAddress +│ ├── security/ +│ │ └── key-handle.ts # KeyHandle with sync use() + async useAsync() +│ └── errors/ +│ └── routes-cli-error.ts # RoutesCliError + ErrorCode enum +│ +├── config/ # ConfigModule (global) +│ ├── config.module.ts +│ ├── config.service.ts # typed getters: getRpcUrl(), getDeadlineOffset(), etc. +│ ├── tokens.config.ts # token definitions (USDC, USDT, bUSDC, bUSDT) +│ └── validation/ +│ └── env.schema.ts # Zod schemas for all env vars +│ +├── blockchain/ # BlockchainModule (global) +│ ├── blockchain.module.ts +│ ├── address-normalizer.service.ts # normalize() / denormalize() — injectable +│ ├── chain-registry.service.ts # explicit bootstrap(), isRegistered() +│ ├── chain-handler.interface.ts # ChainHandler interface +│ ├── chains.config.ts # all chain definitions (prod + dev) +│ ├── chains.service.ts # getChainById(), listChains(), resolveChain() +│ ├── base.publisher.ts # abstract BasePublisher +│ ├── publisher-factory.service.ts # createPublisher(chainType, rpcUrl) +│ ├── rpc.service.ts # withFallback(primary, secondary) — all chain types +│ ├── encoding/ +│ │ ├── portal-encoder.service.ts # ABI (EVM/TVM) + Borsh (SVM) encoding +│ │ └── intent-converter.service.ts # UniversalAddress → chain-native conversion +│ ├── abis/ +│ │ ├── portal.abi.ts +│ │ └── erc20.abi.ts +│ ├── evm/ # all EVM logic together +│ │ ├── evm-chain-handler.ts # EVM address normalize/denormalize +│ │ ├── evm.publisher.ts # viem PublicClient + WalletClient +│ │ └── evm-client-factory.ts # injectable factory for testability +│ ├── tvm/ # all TVM logic together +│ │ ├── tvm-chain-handler.ts # Tron address normalize/denormalize +│ │ ├── tvm.publisher.ts # per-call TronWeb instantiation +│ │ └── tvm-client-factory.ts +│ └── svm/ # all SVM logic together +│ ├── svm-chain-handler.ts # Solana address normalize/denormalize +│ ├── svm.publisher.ts # @solana/web3.js + Anchor +│ ├── svm-client-factory.ts +│ ├── pda-manager.ts # vault, proof, withdrawn_marker PDA derivation +│ ├── transaction-builder.ts # buildFundingTransaction() +│ └── solana-client.ts # setupAnchorProgram(), connection config +│ +├── intent/ # IntentModule +│ ├── intent.module.ts +│ ├── intent-builder.service.ts # pure data assembly — no I/O, no prompts +│ └── intent-storage.service.ts # ~/.routes-cli/intents.json persistence +│ +├── quote/ # QuoteModule +│ ├── quote.module.ts +│ └── quote.service.ts # endpoint selection, request, response normalization +│ +├── status/ # StatusModule +│ ├── status.module.ts +│ └── status.service.ts # status checking for EVM + TVM + SVM +│ +├── cli/ # CliModule (leaf) +│ ├── cli.module.ts +│ ├── services/ +│ │ ├── prompt.service.ts # injectable inquirer wrapper +│ │ └── display.service.ts # injectable ora + cli-table3 wrapper +│ └── commands/ +│ ├── publish.command.ts # @Command('publish') +│ ├── status.command.ts # @Command('status') +│ ├── config.command.ts # @Command('config') +│ ├── chains.command.ts # @Command('chains') +│ └── tokens.command.ts # @Command('tokens') +│ +└── commons/ + └── utils/ # shared pure utilities (no NestJS) +``` + +--- + +## Service Designs + +### `ConfigService` (ConfigModule) + +Wraps `@nestjs/config` with Zod validation. All values are typed — no raw `process.env` access outside this service. + +```typescript +@Injectable() +class ConfigService { + getEvmPrivateKey(): Hex | undefined + getTvmPrivateKey(): string | undefined + getSvmPrivateKey(): string | undefined + getRpcUrl(chainType: ChainType, variant: 'primary' | 'fallback'): string + getQuoteEndpoint(): string // selects SOLVER_URL, preprod, or production + getDeadlineOffsetSeconds(): number // default: 9000 (2.5h), was hardcoded + getDappId(): string // default: 'eco-routes-cli', was hardcoded + isProdEnvironment(): boolean +} +``` + +### `ChainRegistryService` (BlockchainModule) + +Explicit initialization — no side-effect imports. Bootstrapped inside `BlockchainModule.onModuleInit()`. + +```typescript +@Injectable() +class ChainRegistryService implements OnModuleInit { + onModuleInit() { + this.bootstrap([ + new EvmChainHandler(), + new TvmChainHandler(), + new SvmChainHandler(), + ]); + } + + bootstrap(handlers: ChainHandler[]): void + get(chainType: ChainType): ChainHandler + isRegistered(chainId: bigint): boolean + registerChainId(chainId: bigint): void +} +``` + +### `AddressNormalizerService` (BlockchainModule) + +Same API as the current static `AddressNormalizer` class, now injectable. Delegates to `ChainRegistryService`. + +```typescript +@Injectable() +class AddressNormalizerService { + normalize(address: BlockchainAddress, chainType: ChainType): UniversalAddress + denormalize(address: UniversalAddress, chainType: ChainType): BlockchainAddress + denormalizeToEvm(address: UniversalAddress): EvmAddress + denormalizeToTvm(address: UniversalAddress): TronAddress + denormalizeToSvm(address: UniversalAddress): SvmAddress +} +``` + +### `RpcService` (BlockchainModule) + +Uniform RPC fallback for all chain types — fixes the current EVM gap. + +```typescript +@Injectable() +class RpcService { + getUrl(chain: ChainConfig): string // applies withFallback(primary, secondary) + withFallback(primary: () => Promise, fallback: () => Promise): Promise +} +``` + +### `QuoteService` (QuoteModule) + +Pure network concern — extracted from the current monolithic `IntentService`. No prompts, no intent assembly. + +```typescript +@Injectable() +class QuoteService { + async getQuote(params: QuoteRequest): Promise + // Normalizes both v3 wrapped and solver-v2 array response formats + // Throws QuoteServiceError on failure — caller decides fallback behavior +} +``` + +### `IntentBuilder` (IntentModule) + +Pure data assembly — no I/O, no network calls, no prompts. Takes explicit inputs, returns immutable data. + +```typescript +@Injectable() +class IntentBuilder { + buildReward(params: RewardParams): Intent['reward'] + buildManualRoute(params: ManualRouteParams): Intent['route'] + buildFromQuote(params: QuoteRouteParams): Intent['route'] +} +``` + +### `IntentStorage` (IntentModule) + +Implements the missing `~/.routes-cli/intents.json` persistence. + +```typescript +@Injectable() +class IntentStorage { + async save(intent: Intent, result: PublishResult): Promise + async findByHash(intentHash: string): Promise + async listAll(): Promise + async markRefunded(intentHash: string): Promise +} +``` + +### `StatusService` (StatusModule) + +Replaces the EVM-only `status` command. Routes to the correct publisher based on chain type. + +```typescript +@Injectable() +class StatusService { + async getStatus(intentHash: string, chain: ChainConfig): Promise + async watch(intentHash: string, chain: ChainConfig, onUpdate: (s: IntentStatus) => void): Promise +} +``` + +### `PromptService` (CliModule) + +Injectable wrapper around inquirer. All prompt logic centralized here. + +```typescript +@Injectable() +class PromptService { + async selectChain(chains: ChainConfig[], message: string): Promise + async selectToken(tokens: TokenConfig[], label: string): Promise + async inputAmount(symbol: string): Promise<{ raw: string; parsed: bigint }> + async inputAddress(chain: ChainConfig, label: string): Promise + async confirmPublish(summary: IntentSummary): Promise + async inputManualRoute(chain: ChainConfig): Promise + async confirm(message: string, defaultValue?: boolean): Promise +} +``` + +### `DisplayService` (CliModule) + +Injectable wrapper around `ora` and `cli-table3`. + +```typescript +@Injectable() +class DisplayService { + spinner(text: string): void + succeed(text?: string): void + fail(text?: string): void + displayIntentSummary(summary: IntentSummary): void + displayTransactionResult(result: PublishResult): void + displayTable(headers: string[], rows: string[][]): void + displayChains(chains: ChainConfig[]): void + displayTokens(tokens: TokenConfig[]): void +} +``` + +--- + +## Security Changes + +### AsyncKeyHandle — fixes async zeroization race + +```typescript +class KeyHandle { + private buffer: Buffer; + + // Sync variant — kept for synchronous derivations + use(fn: (key: string) => T): T { + try { return fn(this.buffer.toString('utf8')); } + finally { this.buffer.fill(0); } + } + + // Async variant — zeroes buffer only after the promise resolves/rejects + async useAsync(fn: (key: string) => Promise): Promise { + try { return await fn(this.buffer.toString('utf8')); } + finally { this.buffer.fill(0); } + } +} +``` + +Publishers call `keyHandle.useAsync()` directly. No second `KeyHandle` construction needed. + +### TronWeb per-call instantiation — fixes singleton concurrency risk + +```typescript +// tvm.publisher.ts +async publish(..., keyHandle: KeyHandle): Promise { + return keyHandle.useAsync(async (key) => { + const tronWeb = this.tvmClientFactory.create(this.rpcUrl, key); + return this.executePublish(tronWeb, ...); + // tronWeb scoped to this call — no shared state, no key clearing needed + }); +} +``` + +### Chain ID allowlist via `ChainRegistryService` + +All chain IDs registered during `BlockchainModule.onModuleInit()`. `BasePublisher.runPreflightChecks()` injects `ChainRegistryService` and calls `isRegistered(sourceChainId)`. + +--- + +## Data Flow — Publish Intent + +``` +nestjs-commander parses args + options + │ + ▼ +PublishCommand.run(params, options) + │ + ├─► PromptService.selectChain() → ChainsService.listChains() + ├─► PromptService.selectChain() → destination chain + ├─► PromptService.selectToken() → route token on dest + ├─► PromptService.selectToken() → reward token on source + ├─► PromptService.inputAmount() → reward amount as bigint + ├─► PromptService.inputAddress() → recipient → normalize + │ + ├─► KeyHandle.useAsync(key => { + │ senderAddress = getWalletAddress(chainType, key) + │ return publisher.publish(..., key) ← full async publish inside useAsync + │ }) + │ + ├─► QuoteService.getQuote(params) + │ └─► on failure: PromptService.inputManualRoute() + │ + ├─► IntentBuilder.buildReward(params) ← pure, no I/O + │ + ├─► DisplayService.displayIntentSummary() + ├─► PromptService.confirmPublish() + │ + ├─► RpcService.getUrl(sourceChain) ← withFallback applied here + ├─► PublisherFactory.create(chainType, url) + │ + ├─► publisher.publish(source, dest, reward, encodedRoute, keyHandle, portal) + │ + ├─► IntentStorage.save(intent, result) ← always persisted + │ + └─► DisplayService.displayTransactionResult(result) +``` + +--- + +## Module Dependency Graph + +``` +ConfigModule (global) + exports: [ConfigService] + imports: [] + +BlockchainModule (global) + exports: [AddressNormalizerService, ChainsService, ChainRegistryService, + PublisherFactory, RpcService] + imports: [ConfigModule] + +QuoteModule + exports: [QuoteService] + imports: [ConfigModule, BlockchainModule] + +IntentModule + exports: [IntentBuilder, IntentStorage] + imports: [BlockchainModule] + +StatusModule + exports: [StatusService] + imports: [BlockchainModule] + +CliModule ← leaf, nothing imports this + exports: [] + imports: [BlockchainModule, ConfigModule, IntentModule, + QuoteModule, StatusModule] +``` + +--- + +## Issues Resolved + +| # | Issue | Resolution | +|---|-------|------------| +| 1 | Side-effect import order dependency | `ChainRegistryService.onModuleInit()` bootstrap | +| 2 | KeyHandle async race condition | `useAsync()` awaits before zeroing | +| 3 | No intent persistence | `IntentStorage` service | +| 4 | IntentService mixes concerns | Dissolved into `IntentBuilder` + `QuoteService` + `PublishCommand` | +| 5 | No standalone IntentBuilder | `IntentBuilder` as pure injectable service | +| 6 | Missing portal addresses on production chains | Populate in `chains.config.ts` as part of implementation | +| 7 | Status command EVM-only | `StatusService` routes to correct publisher by chain type | +| 8 | RPC fallback EVM-only | `RpcService.withFallback()` uniform across all chain types | +| 9 | No tests | Not a deliverable — designed for testability via injection | +| 10 | Hardcoded dAppID | `ConfigService.getDappId()` | +| 11 | TronWeb singleton state | Per-call `TronWeb` instantiation in `TvmPublisher` | +| 12 | Hardcoded route deadline | `ConfigService.getDeadlineOffsetSeconds()` | + +--- + +## New Dependencies Required + +| Package | Purpose | +|---------|---------| +| `@nestjs/core` | NestJS IoC container | +| `@nestjs/common` | Decorators (`@Injectable`, `@Module`, etc.) | +| `@nestjs/config` | Environment config with validation | +| `nestjs-commander` | CLI command decorators (`@Command`, `@Option`) | +| `reflect-metadata` | Required by NestJS decorators (may already be present) | + +**Packages to remove:** +| Package | Replaced by | +|---------|-------------| +| `commander` | `nestjs-commander` | + +--- + +## Implementation Notes + +1. `tsconfig.json` already has `experimentalDecorators: true` and `emitDecoratorMetadata: true` — NestJS decorator support is ready +2. `nestjs-commander` wraps Commander internally — migration from Commander to `nestjs-commander` is straightforward +3. All existing inquirer prompts migrate verbatim into `PromptService` methods +4. All existing `ora` / `cli-table3` calls migrate verbatim into `DisplayService` methods +5. Chain type subdirectories (`evm/`, `tvm/`, `svm/`) already exist in current codebase — files move, not rewrite +6. `shared/` replaces the need for `src/core/` as a NestJS module — same files, no NestJS wiring diff --git a/docs/plans/2026-02-20-architecture-improvement.md b/docs/plans/2026-02-20-architecture-improvement.md new file mode 100644 index 0000000..89d7d96 --- /dev/null +++ b/docs/plans/2026-02-20-architecture-improvement.md @@ -0,0 +1,1978 @@ +# Architecture Improvement Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform the routes-cli codebase into a clean NestJS-based architecture that resolves all 12 structural issues identified in ARCHITECTURE.md. + +**Architecture:** NestJS standalone application using `nestjs-commander` for CLI commands. All business logic lives in focused injectable services organized into domain modules (`BlockchainModule`, `ConfigModule`, `IntentModule`, `QuoteModule`, `StatusModule`). The `CliModule` is the leaf — it consumes all services but nothing imports it. Plain TypeScript in `shared/` replaces the old `CoreModule`. + +**Tech Stack:** NestJS (`@nestjs/core`, `@nestjs/common`, `@nestjs/config`), `nestjs-commander`, `inquirer`, `ora`, `cli-table3`, `viem`, `tronweb`, `@solana/web3.js`, `@coral-xyz/anchor`, `zod` + +**Design doc:** `docs/plans/2026-02-20-architecture-improvement-design.md` + +--- + +## Phase 1: Foundation & Dependencies + +### Task 1: Install NestJS dependencies + +**Files:** +- Modify: `package.json` + +**Step 1: Install new dependencies** + +```bash +pnpm add @nestjs/core @nestjs/common @nestjs/config nestjs-commander reflect-metadata +``` + +**Step 2: Remove commander (replaced by nestjs-commander)** + +```bash +pnpm remove commander +``` + +**Step 3: Verify install** + +```bash +pnpm typecheck +``` +Expected: No errors (nothing uses commander yet at the type level). + +**Step 4: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "chore(deps): add nestjs + nestjs-commander, remove commander" +``` + +--- + +### Task 2: Create shared/types/ + +These are pure TypeScript types — no NestJS, no side effects. Migrated from `src/core/types/` and `src/core/interfaces/`. + +**Files:** +- Create: `src/shared/types/universal-address.ts` +- Create: `src/shared/types/blockchain-addresses.ts` +- Create: `src/shared/types/intent.interface.ts` + +**Step 1: Create `src/shared/types/universal-address.ts`** + +Copy verbatim from `src/core/types/universal-address.ts`. No changes needed. + +**Step 2: Create `src/shared/types/blockchain-addresses.ts`** + +Copy verbatim from `src/core/types/blockchain-addresses.ts`. No changes needed. + +**Step 3: Create `src/shared/types/intent.interface.ts`** + +Copy verbatim from `src/core/interfaces/intent.ts`. No changes needed. + +**Step 4: Create barrel `src/shared/types/index.ts`** + +```typescript +export * from './universal-address'; +export * from './blockchain-addresses'; +export * from './intent.interface'; +``` + +**Step 5: Verify** + +```bash +pnpm typecheck +``` + +**Step 6: Commit** + +```bash +git add src/shared/ +git commit -m "refactor: add shared/types (migrated from core/types + core/interfaces)" +``` + +--- + +### Task 3: Create shared/security/key-handle.ts + +Adds `useAsync()` — the critical fix for the async key zeroization race condition. + +**Files:** +- Create: `src/shared/security/key-handle.ts` + +**Step 1: Write `src/shared/security/key-handle.ts`** + +```typescript +/** + * A single-use wrapper around a private key string. + * + * Calling use() or useAsync() passes the key to a function and immediately + * zeroizes the internal buffer in a finally block, regardless of success or failure. + * + * use() — synchronous; buffer zeroed after fn() returns + * useAsync() — async-safe; buffer zeroed after the returned Promise settles + */ +export class KeyHandle { + private buffer: Buffer; + + constructor(key: string) { + this.buffer = Buffer.from(key, 'utf8'); + } + + /** + * Synchronous variant. Use for deriving wallet addresses or other + * synchronous key operations. Buffer is zeroed before any async work begins. + */ + use(fn: (key: string) => T): T { + try { + return fn(this.buffer.toString('utf8')); + } finally { + this.buffer.fill(0); + } + } + + /** + * Async-safe variant. Buffer is zeroed only after the promise resolves or rejects. + * Use this when the key needs to survive through async operations (e.g. publisher.publish). + */ + async useAsync(fn: (key: string) => Promise): Promise { + try { + return await fn(this.buffer.toString('utf8')); + } finally { + this.buffer.fill(0); + } + } +} +``` + +**Step 2: Create barrel `src/shared/security/index.ts`** + +```typescript +export * from './key-handle'; +``` + +**Step 3: Verify** + +```bash +pnpm typecheck +``` + +**Step 4: Commit** + +```bash +git add src/shared/security/ +git commit -m "feat(security): add async-safe KeyHandle.useAsync()" +``` + +--- + +### Task 4: Create shared/errors/ + +**Files:** +- Create: `src/shared/errors/routes-cli-error.ts` + +**Step 1: Write `src/shared/errors/routes-cli-error.ts`** + +Copy verbatim from `src/core/errors/errors.ts`. No changes needed. + +**Step 2: Create barrel `src/shared/errors/index.ts`** + +```typescript +export * from './routes-cli-error'; +``` + +**Step 3: Create top-level `src/shared/index.ts`** + +```typescript +export * from './types'; +export * from './security'; +export * from './errors'; +``` + +**Step 4: Verify** + +```bash +pnpm typecheck +``` + +**Step 5: Commit** + +```bash +git add src/shared/errors/ src/shared/index.ts +git commit -m "refactor: add shared/errors (migrated from core/errors)" +``` + +--- + +## Phase 2: ConfigModule + +### Task 5: Create config/validation/env.schema.ts + +**Files:** +- Create: `src/config/validation/env.schema.ts` + +**Step 1: Write the file** + +```typescript +import { z } from 'zod'; + +export const EnvSchema = z.object({ + EVM_PRIVATE_KEY: z.string().regex(/^0x[a-fA-F0-9]{64}$/).optional(), + TVM_PRIVATE_KEY: z.string().regex(/^[a-fA-F0-9]{64}$/).optional(), + SVM_PRIVATE_KEY: z.string().min(1).optional(), + + EVM_RPC_URL: z.string().url().optional(), + TVM_RPC_URL: z.string().url().default('https://api.trongrid.io'), + TVM_RPC_URL_2: z.string().url().default('https://tron.publicnode.com'), + SVM_RPC_URL: z.string().url().default('https://api.mainnet-beta.solana.com'), + SVM_RPC_URL_2: z.string().url().default('https://solana.publicnode.com'), + + SOLVER_URL: z.string().url().optional(), + QUOTES_API_URL: z.string().optional(), + QUOTES_PREPROD: z.string().optional(), + + NODE_CHAINS_ENV: z.enum(['production', 'development']).default('production'), + DEBUG: z.string().optional(), + + DAPP_ID: z.string().default('eco-routes-cli'), + DEADLINE_OFFSET_SECONDS: z.coerce.number().positive().default(9000), +}); + +export type EnvConfig = z.infer; +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/config/validation/ +git commit -m "feat(config): add Zod env validation schema with configurable dAppID + deadline" +``` + +--- + +### Task 6: Create config/config.service.ts + +**Files:** +- Create: `src/config/config.service.ts` + +**Step 1: Write the file** + +```typescript +import { Injectable } from '@nestjs/common'; +import { ConfigService as NestConfigService } from '@nestjs/config'; +import { Hex } from 'viem'; +import { ChainType } from '@/shared/types'; + +@Injectable() +export class ConfigService { + constructor(private readonly config: NestConfigService) {} + + getEvmPrivateKey(): Hex | undefined { + return this.config.get('EVM_PRIVATE_KEY'); + } + + getTvmPrivateKey(): string | undefined { + return this.config.get('TVM_PRIVATE_KEY'); + } + + getSvmPrivateKey(): string | undefined { + return this.config.get('SVM_PRIVATE_KEY'); + } + + getRpcUrl(chainType: ChainType, variant: 'primary' | 'fallback' = 'primary'): string | undefined { + const map: Record> = { + [ChainType.EVM]: { + primary: this.config.get('EVM_RPC_URL') ?? '', + fallback: '', // EVM fallback not configured via env — handled per-chain + }, + [ChainType.TVM]: { + primary: this.config.get('TVM_RPC_URL') ?? 'https://api.trongrid.io', + fallback: this.config.get('TVM_RPC_URL_2') ?? 'https://tron.publicnode.com', + }, + [ChainType.SVM]: { + primary: this.config.get('SVM_RPC_URL') ?? 'https://api.mainnet-beta.solana.com', + fallback: this.config.get('SVM_RPC_URL_2') ?? 'https://solana.publicnode.com', + }, + }; + return map[chainType][variant] || undefined; + } + + getQuoteEndpoint(): { url: string; type: 'solver-v2' | 'preprod' | 'production' } { + const solverUrl = this.config.get('SOLVER_URL'); + if (solverUrl) { + return { url: `${solverUrl}/api/v2/quote/reverse`, type: 'solver-v2' }; + } + if (this.config.get('QUOTES_API_URL') || this.config.get('QUOTES_PREPROD')) { + return { url: 'https://quotes-preprod.eco.com/api/v3/quotes/single', type: 'preprod' }; + } + return { url: 'https://quotes.eco.com/api/v3/quotes/single', type: 'production' }; + } + + getDeadlineOffsetSeconds(): number { + return this.config.get('DEADLINE_OFFSET_SECONDS') ?? 9000; + } + + getDappId(): string { + return this.config.get('DAPP_ID') ?? 'eco-routes-cli'; + } + + getChainsEnv(): 'production' | 'development' { + return this.config.get<'production' | 'development'>('NODE_CHAINS_ENV') ?? 'production'; + } + + isDebug(): boolean { + return !!this.config.get('DEBUG'); + } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/config/config.service.ts +git commit -m "feat(config): add typed ConfigService with all env getters" +``` + +--- + +### Task 7: Create config/tokens.config.ts + +**Files:** +- Create: `src/config/tokens.config.ts` + +**Step 1: Write the file** + +Copy `src/config/tokens.ts` to `src/config/tokens.config.ts`. Update all imports from `@/core/` to `@/shared/`. No logic changes. + +**Step 2: Create config/config.module.ts** + +```typescript +import { Module, Global } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import { ConfigService } from './config.service'; +import { EnvSchema } from './validation/env.schema'; + +@Global() +@Module({ + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + validate: (config) => EnvSchema.parse(config), + }), + ], + providers: [ConfigService], + exports: [ConfigService], +}) +export class ConfigModule {} +``` + +**Step 3: Verify** + +```bash +pnpm typecheck +``` + +**Step 4: Commit** + +```bash +git add src/config/ +git commit -m "feat(config): add ConfigModule with global Zod-validated config" +``` + +--- + +## Phase 3: BlockchainModule + +### Task 8: Create chain-handler.interface.ts and chain-registry.service.ts + +**Files:** +- Create: `src/blockchain/chain-handler.interface.ts` +- Create: `src/blockchain/chain-registry.service.ts` + +**Step 1: Create `src/blockchain/chain-handler.interface.ts`** + +Copy verbatim from `src/core/chain/chain-handler.interface.ts`. Update imports to use `@/shared/`. + +**Step 2: Create `src/blockchain/chain-registry.service.ts`** + +```typescript +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ChainType } from '@/shared/types'; +import { RoutesCliError } from '@/shared/errors'; +import { ChainHandler } from './chain-handler.interface'; +import { EvmChainHandler } from './evm/evm-chain-handler'; +import { TvmChainHandler } from './tvm/tvm-chain-handler'; +import { SvmChainHandler } from './svm/svm-chain-handler'; + +@Injectable() +export class ChainRegistryService implements OnModuleInit { + private readonly handlers = new Map(); + private readonly registeredChainIds = new Set(); + + onModuleInit(): void { + this.bootstrap([ + new EvmChainHandler(), + new TvmChainHandler(), + new SvmChainHandler(), + ]); + } + + bootstrap(handlers: ChainHandler[]): void { + for (const handler of handlers) { + this.handlers.set(handler.chainType, handler); + } + } + + get(chainType: ChainType): ChainHandler { + const handler = this.handlers.get(chainType); + if (!handler) throw RoutesCliError.unsupportedChain(chainType); + return handler; + } + + getAll(): ChainHandler[] { + return [...this.handlers.values()]; + } + + registerChainId(chainId: bigint): void { + this.registeredChainIds.add(chainId); + } + + isRegistered(chainId: bigint): boolean { + return this.registeredChainIds.has(chainId); + } +} +``` + +**Step 3: Verify** + +```bash +pnpm typecheck +``` + +**Step 4: Commit** + +```bash +git add src/blockchain/chain-handler.interface.ts src/blockchain/chain-registry.service.ts +git commit -m "feat(blockchain): add ChainRegistryService with explicit onModuleInit bootstrap" +``` + +--- + +### Task 9: Migrate EVM, TVM, SVM chain handlers + +**Files:** +- Create: `src/blockchain/evm/evm-chain-handler.ts` +- Create: `src/blockchain/tvm/tvm-chain-handler.ts` +- Create: `src/blockchain/svm/svm-chain-handler.ts` + +**Step 1:** Copy each handler from `src/blockchain/evm/evm-chain-handler.ts`, `src/blockchain/tvm/tvm-chain-handler.ts`, `src/blockchain/svm/svm-chain-handler.ts` (current location). + +Update each to: +- Remove the `chainRegistry.register(new XxxChainHandler())` self-registration line at the bottom (registration now happens in `ChainRegistryService.onModuleInit()`) +- Update all imports to use `@/shared/` and `@/blockchain/chain-handler.interface` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/evm/evm-chain-handler.ts src/blockchain/tvm/tvm-chain-handler.ts src/blockchain/svm/svm-chain-handler.ts +git commit -m "refactor(blockchain): migrate chain handlers, remove self-registration side effects" +``` + +--- + +### Task 10: Create address-normalizer.service.ts + +**Files:** +- Create: `src/blockchain/address-normalizer.service.ts` + +**Step 1: Write the file** + +```typescript +import { Injectable } from '@nestjs/common'; +import { ChainType, UniversalAddress, BlockchainAddress, EvmAddress, TronAddress, SvmAddress } from '@/shared/types'; +import { ChainRegistryService } from './chain-registry.service'; + +@Injectable() +export class AddressNormalizerService { + constructor(private readonly registry: ChainRegistryService) {} + + normalize(address: BlockchainAddress, chainType: ChainType): UniversalAddress { + return this.registry.get(chainType).normalize(address as string); + } + + denormalize(address: UniversalAddress, chainType: ChainType): BlockchainAddress { + return this.registry.get(chainType).denormalize(address); + } + + denormalizeToEvm(address: UniversalAddress): EvmAddress { + return this.registry.get(ChainType.EVM).denormalize(address) as EvmAddress; + } + + denormalizeToTvm(address: UniversalAddress): TronAddress { + return this.registry.get(ChainType.TVM).denormalize(address) as TronAddress; + } + + denormalizeToSvm(address: UniversalAddress): SvmAddress { + return this.registry.get(ChainType.SVM).denormalize(address) as SvmAddress; + } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/address-normalizer.service.ts +git commit -m "feat(blockchain): add injectable AddressNormalizerService" +``` + +--- + +### Task 11: Create chains.config.ts and chains.service.ts + +**Files:** +- Create: `src/blockchain/chains.config.ts` +- Create: `src/blockchain/chains.service.ts` + +**Step 1: Create `src/blockchain/chains.config.ts`** + +Copy `src/config/chains.ts` to `src/blockchain/chains.config.ts`. Update imports to use `@/shared/` and `@/blockchain/address-normalizer.service`. Remove any calls to the old static `AddressNormalizer` — chain config will store raw strings and normalize lazily via `ChainsService`, OR normalize at construction time passing the service. + +> Note: Because config is loaded at module init time and `AddressNormalizerService` requires `ChainRegistryService` to be initialized first, address normalization in chains config must happen in `ChainsService.onModuleInit()`, not at file load time. + +Update `chains.config.ts` to export raw chain definitions with addresses as plain strings (pre-normalization), using a new type: + +```typescript +export interface RawChainConfig { + id: bigint; + name: string; + env: 'production' | 'development'; + type: ChainType; + rpcUrl: string; + portalAddress?: string; // raw string, normalized by ChainsService + proverAddress?: string; + nativeCurrency: { name: string; symbol: string; decimals: number }; +} +``` + +**Step 2: Create `src/blockchain/chains.service.ts`** + +```typescript +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@/config/config.service'; +import { ChainConfig, ChainType, UniversalAddress } from '@/shared/types'; +import { RoutesCliError } from '@/shared/errors'; +import { RAW_CHAIN_CONFIGS, RawChainConfig } from './chains.config'; +import { AddressNormalizerService } from './address-normalizer.service'; +import { ChainRegistryService } from './chain-registry.service'; + +@Injectable() +export class ChainsService implements OnModuleInit { + private chains: ChainConfig[] = []; + + constructor( + private readonly config: ConfigService, + private readonly normalizer: AddressNormalizerService, + private readonly registry: ChainRegistryService, + ) {} + + onModuleInit(): void { + const env = this.config.getChainsEnv(); + this.chains = RAW_CHAIN_CONFIGS + .filter(c => c.env === env || c.env === 'production') + .map(c => this.normalizeChain(c)); + + // Register all chain IDs in the allowlist + for (const chain of this.chains) { + this.registry.registerChainId(chain.id); + } + } + + private normalizeChain(raw: RawChainConfig): ChainConfig { + return { + ...raw, + portalAddress: raw.portalAddress + ? this.normalizer.normalize(raw.portalAddress as any, raw.type) + : undefined, + proverAddress: raw.proverAddress + ? this.normalizer.normalize(raw.proverAddress as any, raw.type) + : undefined, + }; + } + + listChains(): ChainConfig[] { + return this.chains; + } + + getChainById(id: bigint): ChainConfig { + const chain = this.chains.find(c => c.id === id); + if (!chain) throw RoutesCliError.unsupportedChain(id); + return chain; + } + + getChainByName(name: string): ChainConfig { + const chain = this.chains.find(c => c.name.toLowerCase() === name.toLowerCase()); + if (!chain) throw RoutesCliError.unsupportedChain(name); + return chain; + } + + resolveChain(nameOrId: string): ChainConfig { + const asId = BigInt(nameOrId); + if (asId) return this.getChainById(asId); + return this.getChainByName(nameOrId); + } +} +``` + +**Step 3: Verify** + +```bash +pnpm typecheck +``` + +**Step 4: Commit** + +```bash +git add src/blockchain/chains.config.ts src/blockchain/chains.service.ts +git commit -m "feat(blockchain): add ChainsService with lazy normalization in onModuleInit" +``` + +--- + +### Task 12: Create rpc.service.ts + +**Files:** +- Create: `src/blockchain/rpc.service.ts` + +**Step 1: Write the file** + +```typescript +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@/config/config.service'; +import { ChainConfig, ChainType } from '@/shared/types'; + +@Injectable() +export class RpcService { + constructor(private readonly config: ConfigService) {} + + getUrl(chain: ChainConfig): string { + // Chain-specific RPC overrides env override default + const envOverride = this.config.getRpcUrl(chain.type, 'primary'); + return envOverride || chain.rpcUrl; + } + + getFallbackUrl(chain: ChainConfig): string | undefined { + return this.config.getRpcUrl(chain.type, 'fallback') || undefined; + } + + async withFallback( + primary: () => Promise, + fallback: () => Promise, + ): Promise { + try { + return await primary(); + } catch { + return fallback(); + } + } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/rpc.service.ts +git commit -m "feat(blockchain): add RpcService with uniform withFallback() for all chain types" +``` + +--- + +### Task 13: Migrate publishers + +**Files:** +- Create: `src/blockchain/base.publisher.ts` +- Modify: `src/blockchain/evm/evm.publisher.ts` +- Modify: `src/blockchain/tvm/tvm.publisher.ts` +- Modify: `src/blockchain/svm/svm.publisher.ts` + +**Step 1: Create `src/blockchain/base.publisher.ts`** + +Copy from `src/blockchain/base-publisher.ts`. Add `@Injectable()` decorator. Update imports to `@/shared/`. Change constructor signature to inject `ChainRegistryService`: + +```typescript +import { Injectable } from '@nestjs/common'; +import { ChainRegistryService } from './chain-registry.service'; + +@Injectable() +export abstract class BasePublisher { + constructor( + protected readonly rpcUrl: string, + protected readonly registry: ChainRegistryService, + ) {} + + protected runPreflightChecks(sourceChainId: bigint): void { + if (!this.registry.isRegistered(sourceChainId)) { + throw RoutesCliError.unsupportedChain(sourceChainId); + } + } + + // ... rest of abstract methods unchanged +} +``` + +**Step 2: Update `src/blockchain/evm/evm.publisher.ts`** + +- Add `@Injectable()` decorator +- Update `publish()` to use `keyHandle.useAsync()` instead of `keyHandle.use()` + second KeyHandle construction +- Inject `ChainRegistryService` via constructor +- Update all imports to `@/shared/` + +Key change in publish(): +```typescript +// Before (current code): +const { senderAccount } = keyHandle.use(rawKey => ({ + senderAccount: privateKeyToAccount(rawKey as Hex), +})); +const publishKeyHandle = new KeyHandle(rawKey); // this is wrong - rawKey not in scope + +// After: +return keyHandle.useAsync(async (rawKey) => { + const senderAccount = privateKeyToAccount(rawKey as Hex); + // ... all async publisher logic here, key alive for the duration +}); +``` + +**Step 3: Update `src/blockchain/tvm/tvm.publisher.ts`** + +- Add `@Injectable()` decorator +- Change from singleton TronWeb to per-call instantiation: + +```typescript +// Before: single this.tronWeb instance, key set/clear pattern +// After: +return keyHandle.useAsync(async (rawKey) => { + const tronWeb = this.factory.create(this.rpcUrl, rawKey); + // tronWeb scoped to this call, no finally needed + return this.executePublish(tronWeb, ...); +}); +``` + +**Step 4: Update `src/blockchain/svm/svm.publisher.ts`** + +- Add `@Injectable()` decorator +- Update to use `keyHandle.useAsync()` +- Update imports to `@/shared/` + +**Step 5: Verify** + +```bash +pnpm typecheck +``` + +**Step 6: Commit** + +```bash +git add src/blockchain/base.publisher.ts src/blockchain/evm/evm.publisher.ts src/blockchain/tvm/tvm.publisher.ts src/blockchain/svm/svm.publisher.ts +git commit -m "feat(blockchain): migrate publishers to injectable NestJS services with useAsync()" +``` + +--- + +### Task 14: Migrate SVM helpers + +**Files:** +- Modify: `src/blockchain/svm/pda-manager.ts` +- Modify: `src/blockchain/svm/transaction-builder.ts` +- Modify: `src/blockchain/svm/solana-client.ts` + +**Step 1:** Copy these three files from their current locations. Update imports to `@/shared/`. + +No logic changes required — these are already pure functions / utility modules. + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/svm/ +git commit -m "refactor(blockchain/svm): migrate SVM helpers to new structure" +``` + +--- + +### Task 15: Migrate client factories + +**Files:** +- Create: `src/blockchain/evm/evm-client-factory.ts` +- Create: `src/blockchain/tvm/tvm-client-factory.ts` +- Create: `src/blockchain/svm/svm-client-factory.ts` + +**Step 1:** Copy each from current `src/blockchain/evm/`, `tvm/`, `svm/` locations. Update imports to `@/shared/`. No logic changes. + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/evm/evm-client-factory.ts src/blockchain/tvm/tvm-client-factory.ts src/blockchain/svm/svm-client-factory.ts +git commit -m "refactor(blockchain): migrate client factories to co-located chain dirs" +``` + +--- + +### Task 16: Create publisher-factory.service.ts + +**Files:** +- Create: `src/blockchain/publisher-factory.service.ts` + +**Step 1: Write the file** + +```typescript +import { Injectable } from '@nestjs/common'; +import { ChainType } from '@/shared/types'; +import { BasePublisher } from './base.publisher'; +import { EvmPublisher } from './evm/evm.publisher'; +import { TvmPublisher } from './tvm/tvm.publisher'; +import { SvmPublisher } from './svm/svm.publisher'; +import { ChainRegistryService } from './chain-registry.service'; +import { RpcService } from './rpc.service'; +import { ChainConfig } from '@/shared/types'; + +@Injectable() +export class PublisherFactory { + constructor( + private readonly registry: ChainRegistryService, + private readonly rpcService: RpcService, + ) {} + + create(chain: ChainConfig): BasePublisher { + const rpcUrl = this.rpcService.getUrl(chain); + switch (chain.type) { + case ChainType.EVM: + return new EvmPublisher(rpcUrl, this.registry); + case ChainType.TVM: + return new TvmPublisher(rpcUrl, this.registry); + case ChainType.SVM: + return new SvmPublisher(rpcUrl, this.registry); + default: + throw new Error(`Unsupported chain type: ${chain.type}`); + } + } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/publisher-factory.service.ts +git commit -m "feat(blockchain): add PublisherFactory as injectable NestJS service" +``` + +--- + +### Task 17: Migrate encoding services + +**Files:** +- Create: `src/blockchain/encoding/portal-encoder.service.ts` +- Create: `src/blockchain/encoding/intent-converter.service.ts` + +**Step 1:** Copy from `src/core/utils/portal-encoder.ts` and `src/core/utils/intent-converter.ts`. + +- Add `@Injectable()` decorator to each +- Convert static methods to instance methods +- Inject `AddressNormalizerService` instead of using static `AddressNormalizer` +- Update all imports to `@/shared/` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/encoding/ +git commit -m "feat(blockchain): migrate PortalEncoder and IntentConverter to injectable services" +``` + +--- + +### Task 18: Create blockchain.module.ts + +**Files:** +- Create: `src/blockchain/blockchain.module.ts` + +**Step 1: Write the file** + +```typescript +import { Global, Module } from '@nestjs/common'; +import { ChainRegistryService } from './chain-registry.service'; +import { AddressNormalizerService } from './address-normalizer.service'; +import { ChainsService } from './chains.service'; +import { RpcService } from './rpc.service'; +import { PublisherFactory } from './publisher-factory.service'; +import { PortalEncoderService } from './encoding/portal-encoder.service'; +import { IntentConverterService } from './encoding/intent-converter.service'; + +@Global() +@Module({ + providers: [ + ChainRegistryService, + AddressNormalizerService, + ChainsService, + RpcService, + PublisherFactory, + PortalEncoderService, + IntentConverterService, + ], + exports: [ + ChainRegistryService, + AddressNormalizerService, + ChainsService, + RpcService, + PublisherFactory, + PortalEncoderService, + IntentConverterService, + ], +}) +export class BlockchainModule {} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/blockchain/blockchain.module.ts +git commit -m "feat(blockchain): assemble BlockchainModule (global)" +``` + +--- + +## Phase 4: QuoteModule + +### Task 19: Create quote/quote.service.ts + +**Files:** +- Create: `src/quote/quote.service.ts` + +**Step 1:** Copy logic from `src/core/utils/quote.ts`. Convert to `@Injectable()` class. Replace hardcoded `'eco-routes-cli'` and endpoint selection with `ConfigService` injected via constructor: + +```typescript +@Injectable() +export class QuoteService { + constructor(private readonly config: ConfigService) {} + + async getQuote(params: QuoteRequest): Promise { + const { url, type } = this.config.getQuoteEndpoint(); + const dAppID = this.config.getDappId(); + // ... rest of logic unchanged, uses url + dAppID from config + } +} +``` + +**Step 2: Create `src/quote/quote.module.ts`** + +```typescript +import { Module } from '@nestjs/common'; +import { QuoteService } from './quote.service'; + +@Module({ + providers: [QuoteService], + exports: [QuoteService], +}) +export class QuoteModule {} +``` + +**Step 3: Verify** + +```bash +pnpm typecheck +``` + +**Step 4: Commit** + +```bash +git add src/quote/ +git commit -m "feat(quote): add injectable QuoteService with configurable endpoint + dAppID" +``` + +--- + +## Phase 5: IntentModule + +### Task 20: Create intent/intent-builder.service.ts + +**Files:** +- Create: `src/intent/intent-builder.service.ts` + +**Step 1: Write the file** + +Extract the pure intent/reward assembly logic from the current `IntentService`. No prompts, no network calls. + +```typescript +import { Injectable } from '@nestjs/common'; +import { Hex } from 'viem'; +import { Intent, UniversalAddress, ChainConfig } from '@/shared/types'; +import { AddressNormalizerService } from '@/blockchain/address-normalizer.service'; +import { PortalEncoderService } from '@/blockchain/encoding/portal-encoder.service'; +import { ConfigService } from '@/config/config.service'; + +export interface RewardParams { + sourceChain: ChainConfig; + creator: UniversalAddress; + prover: UniversalAddress; + rewardToken: UniversalAddress; + rewardAmount: bigint; + deadline?: bigint; +} + +export interface ManualRouteParams { + destChain: ChainConfig; + recipient: UniversalAddress; + routeToken: UniversalAddress; + routeAmount: bigint; + portal: UniversalAddress; + deadline?: bigint; +} + +@Injectable() +export class IntentBuilder { + constructor( + private readonly config: ConfigService, + private readonly encoder: PortalEncoderService, + private readonly normalizer: AddressNormalizerService, + ) {} + + buildReward(params: RewardParams): Intent['reward'] { + const deadlineOffset = BigInt(this.config.getDeadlineOffsetSeconds()); + const deadline = params.deadline ?? BigInt(Math.floor(Date.now() / 1000)) + deadlineOffset; + return { + deadline, + creator: params.creator, + prover: params.prover, + nativeAmount: 0n, + tokens: [{ token: params.rewardToken, amount: params.rewardAmount }], + }; + } + + buildManualRoute(params: ManualRouteParams): { encodedRoute: Hex; route: Intent['route'] } { + const deadlineOffset = BigInt(this.config.getDeadlineOffsetSeconds()); + const deadline = params.deadline ?? BigInt(Math.floor(Date.now() / 1000)) + deadlineOffset; + const salt = this.generateSalt(); + + // Build ERC-20 transfer call to recipient + const transferData = this.encoder.encodeErc20Transfer(params.recipient, params.routeAmount); + const route: Intent['route'] = { + salt, + deadline, + portal: params.portal, + nativeAmount: 0n, + tokens: [{ token: params.routeToken, amount: params.routeAmount }], + calls: [{ target: params.routeToken, data: transferData, value: 0n }], + }; + + const encodedRoute = this.encoder.encode(route, params.destChain.type); + return { encodedRoute, route }; + } + + private generateSalt(): Hex { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return `0x${Buffer.from(bytes).toString('hex')}` as Hex; + } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/intent/intent-builder.service.ts +git commit -m "feat(intent): add pure IntentBuilder service — no I/O, no prompts" +``` + +--- + +### Task 21: Create intent/intent-storage.service.ts + +**Files:** +- Create: `src/intent/intent-storage.service.ts` + +**Step 1: Write the file** + +```typescript +import { Injectable } from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { Intent } from '@/shared/types'; +import { PublishResult } from '@/blockchain/base.publisher'; + +export interface StoredIntent { + intentHash: string; + sourceChainId: string; + destChainId: string; + reward: unknown; + routeHash: string; + publishedAt: number; + refundedAt: number | null; + transactionHash: string; +} + +@Injectable() +export class IntentStorage { + private readonly storePath = path.join(os.homedir(), '.routes-cli', 'intents.json'); + + async save(intent: Intent, result: PublishResult): Promise { + const intents = await this.readAll(); + const entry: StoredIntent = { + intentHash: result.intentHash ?? '', + sourceChainId: intent.sourceChainId.toString(), + destChainId: intent.destination.toString(), + reward: intent.reward, + routeHash: '', + publishedAt: Math.floor(Date.now() / 1000), + refundedAt: null, + transactionHash: result.transactionHash ?? '', + }; + intents.push(entry); + await this.writeAll(intents); + } + + async findByHash(intentHash: string): Promise { + const intents = await this.readAll(); + return intents.find(i => i.intentHash === intentHash) ?? null; + } + + async listAll(): Promise { + return this.readAll(); + } + + async markRefunded(intentHash: string): Promise { + const intents = await this.readAll(); + const entry = intents.find(i => i.intentHash === intentHash); + if (entry) { + entry.refundedAt = Math.floor(Date.now() / 1000); + await this.writeAll(intents); + } + } + + private async readAll(): Promise { + try { + const raw = await fs.readFile(this.storePath, 'utf8'); + return JSON.parse(raw, (_, v) => typeof v === 'string' && /^\d+n$/.test(v) ? BigInt(v.slice(0, -1)) : v); + } catch { + return []; + } + } + + private async writeAll(intents: StoredIntent[]): Promise { + await fs.mkdir(path.dirname(this.storePath), { recursive: true }); + await fs.writeFile(this.storePath, JSON.stringify(intents, (_, v) => typeof v === 'bigint' ? `${v}n` : v, 2)); + } +} +``` + +**Step 2: Create `src/intent/intent.module.ts`** + +```typescript +import { Module } from '@nestjs/common'; +import { IntentBuilder } from './intent-builder.service'; +import { IntentStorage } from './intent-storage.service'; + +@Module({ + providers: [IntentBuilder, IntentStorage], + exports: [IntentBuilder, IntentStorage], +}) +export class IntentModule {} +``` + +**Step 3: Verify** + +```bash +pnpm typecheck +``` + +**Step 4: Commit** + +```bash +git add src/intent/ +git commit -m "feat(intent): add IntentBuilder + IntentStorage services and IntentModule" +``` + +--- + +## Phase 6: StatusModule + +### Task 22: Create status/status.service.ts + +**Files:** +- Create: `src/status/status.service.ts` +- Create: `src/status/status.module.ts` + +**Step 1: Write `src/status/status.service.ts`** + +Extract the EVM status logic from `src/commands/status.ts`. Add TVM and SVM status checking via the respective publishers' new `checkStatus()` method on `BasePublisher`. + +```typescript +import { Injectable } from '@nestjs/common'; +import { ChainConfig, ChainType } from '@/shared/types'; +import { PublisherFactory } from '@/blockchain/publisher-factory.service'; + +export interface IntentStatus { + fulfilled: boolean; + solver?: string; + fulfillmentTxHash?: string; + blockNumber?: bigint; + timestamp?: number; +} + +@Injectable() +export class StatusService { + constructor(private readonly publisherFactory: PublisherFactory) {} + + async getStatus(intentHash: string, chain: ChainConfig): Promise { + const publisher = this.publisherFactory.create(chain); + return publisher.getStatus(intentHash, chain.id); + } + + async watch( + intentHash: string, + chain: ChainConfig, + onUpdate: (status: IntentStatus) => void, + intervalMs = 10_000, + ): Promise { + let last: IntentStatus | null = null; + while (true) { + const status = await this.getStatus(intentHash, chain); + if (!last || status.fulfilled !== last.fulfilled) { + onUpdate(status); + last = status; + } + if (status.fulfilled) break; + await new Promise(r => setTimeout(r, intervalMs)); + } + } +} +``` + +**Step 2: Add `getStatus()` abstract method to `BasePublisher`** + +```typescript +abstract getStatus(intentHash: string, chainId: bigint): Promise; +``` + +Implement in EVM publisher (extract from current `status.ts`). Add stub implementations to TVM and SVM publishers that throw `'Not yet implemented'` — this makes TVM/SVM status a tracked gap without blocking the rest. + +**Step 3: Create `src/status/status.module.ts`** + +```typescript +import { Module } from '@nestjs/common'; +import { StatusService } from './status.service'; + +@Module({ + providers: [StatusService], + exports: [StatusService], +}) +export class StatusModule {} +``` + +**Step 4: Verify** + +```bash +pnpm typecheck +``` + +**Step 5: Commit** + +```bash +git add src/status/ +git commit -m "feat(status): add StatusService routing status checks by chain type" +``` + +--- + +## Phase 7: CliModule + +### Task 23: Create cli/services/prompt.service.ts + +**Files:** +- Create: `src/cli/services/prompt.service.ts` + +**Step 1:** Extract all inquirer calls from the current `src/cli/prompts/intent-prompts.ts` and `src/commands/publish.ts` into a single injectable class. Methods map 1:1 to existing prompt logic — no UX changes. + +```typescript +import { Injectable } from '@nestjs/common'; +import inquirer from 'inquirer'; +import { ChainConfig, TokenConfig } from '@/shared/types'; + +@Injectable() +export class PromptService { + async selectChain(chains: ChainConfig[], message: string): Promise { + const { chain } = await inquirer.prompt([{ + type: 'list', name: 'chain', message, + choices: chains.map(c => ({ name: `${c.name} (${c.id})`, value: c })), + }]); + return chain; + } + + async selectToken(tokens: TokenConfig[], label: string): Promise<{ address: string; decimals: number; symbol?: string }> { + // ... existing logic from intent-prompts.ts + } + + async inputAmount(symbol: string): Promise<{ raw: string; parsed: bigint; decimals: number }> { + // ... existing logic + } + + async inputAddress(chain: ChainConfig, label: string, defaultValue?: string): Promise { + // ... existing logic + } + + async confirmPublish(): Promise { + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', name: 'confirmed', message: 'Publish this intent?', default: true, + }]); + return confirmed; + } + + async confirm(message: string, defaultValue = false): Promise { + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', name: 'confirmed', message, default: defaultValue, + }]); + return confirmed; + } + + async inputManualPortal(chain: ChainConfig): Promise { + // ... existing fallback logic + } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/cli/services/prompt.service.ts +git commit -m "feat(cli): add injectable PromptService wrapping all inquirer calls" +``` + +--- + +### Task 24: Create cli/services/display.service.ts + +**Files:** +- Create: `src/cli/services/display.service.ts` + +**Step 1:** Extract all `ora` and `cli-table3` calls from `src/utils/logger.ts` into an injectable class. Methods map 1:1 — no UX changes. + +```typescript +import { Injectable } from '@nestjs/common'; +import ora, { Ora } from 'ora'; +import Table from 'cli-table3'; +import chalk from 'chalk'; +import { PublishResult } from '@/blockchain/base.publisher'; +import { ChainConfig, TokenConfig } from '@/shared/types'; + +@Injectable() +export class DisplayService { + private activeSpinner: Ora | null = null; + + spinner(text: string): void { + this.stopSpinner(); + this.activeSpinner = ora(text).start(); + } + + succeed(text?: string): void { this.activeSpinner?.succeed(text); this.activeSpinner = null; } + fail(text?: string): void { this.activeSpinner?.fail(text); this.activeSpinner = null; } + warn(text?: string): void { this.activeSpinner?.warn(text); this.activeSpinner = null; } + stopSpinner(): void { this.activeSpinner?.stop(); this.activeSpinner = null; } + + log(msg: string): void { console.log(chalk.gray(msg)); } + success(msg: string): void { console.log(chalk.green(`✅ ${msg}`)); } + error(msg: string): void { console.error(chalk.red(`❌ ${msg}`)); } + warning(msg: string): void { console.warn(chalk.yellow(`⚠️ ${msg}`)); } + title(msg: string): void { console.log(chalk.bold.blue(msg)); } + section(msg: string): void { console.log(chalk.blue(msg)); } + + displayTable(headers: string[], rows: string[][]): void { + const table = new Table({ head: headers.map(h => chalk.cyan(h)), style: { border: ['gray'] } }); + rows.forEach(row => table.push(row)); + console.log(table.toString()); + } + + displayTransactionResult(result: PublishResult): void { + this.displayTable(['Field', 'Value'], [ + ['Transaction Hash', result.transactionHash ?? '-'], + ['Intent Hash', result.intentHash ?? '-'], + ['Vault Address', result.vaultAddress ?? '-'], + ]); + } + + displayChains(chains: ChainConfig[]): void { + this.displayTable( + ['Name', 'ID', 'Type', 'Native Currency'], + chains.map(c => [c.name, c.id.toString(), c.type, c.nativeCurrency.symbol]), + ); + } + + displayTokens(tokens: TokenConfig[]): void { + this.displayTable( + ['Symbol', 'Name', 'Decimals', 'Available Chains'], + tokens.map(t => [t.symbol, t.name, t.decimals.toString(), Object.keys(t.addresses).join(', ')]), + ); + } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/cli/services/display.service.ts +git commit -m "feat(cli): add injectable DisplayService wrapping ora + cli-table3" +``` + +--- + +### Task 25: Create cli/commands/publish.command.ts + +**Files:** +- Create: `src/cli/commands/publish.command.ts` + +**Step 1: Write the file** + +This is the thin orchestrator — prompts + service calls, no business logic: + +```typescript +import { Command, CommandRunner, Option } from 'nestjs-commander'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@/config/config.service'; +import { ChainsService } from '@/blockchain/chains.service'; +import { AddressNormalizerService } from '@/blockchain/address-normalizer.service'; +import { PublisherFactory } from '@/blockchain/publisher-factory.service'; +import { QuoteService } from '@/quote/quote.service'; +import { IntentBuilder } from '@/intent/intent-builder.service'; +import { IntentStorage } from '@/intent/intent-storage.service'; +import { PromptService } from '../services/prompt.service'; +import { DisplayService } from '../services/display.service'; +import { KeyHandle } from '@/shared/security'; + +interface PublishOptions { + source?: string; + destination?: string; + privateKey?: string; + rpc?: string; + recipient?: string; + dryRun?: boolean; +} + +@Injectable() +@Command({ name: 'publish', description: 'Publish an intent to the blockchain' }) +export class PublishCommand extends CommandRunner { + constructor( + private readonly chains: ChainsService, + private readonly config: ConfigService, + private readonly normalizer: AddressNormalizerService, + private readonly publisherFactory: PublisherFactory, + private readonly quoteService: QuoteService, + private readonly intentBuilder: IntentBuilder, + private readonly intentStorage: IntentStorage, + private readonly prompt: PromptService, + private readonly display: DisplayService, + ) { + super(); + } + + async run(_params: string[], options: PublishOptions): Promise { + this.display.title('🎨 Interactive Intent Publishing'); + + const allChains = this.chains.listChains(); + const sourceChain = options.source + ? this.chains.resolveChain(options.source) + : await this.prompt.selectChain(allChains, 'Select source chain:'); + + const destChain = options.destination + ? this.chains.resolveChain(options.destination) + : await this.prompt.selectChain(allChains.filter(c => c.id !== sourceChain.id), 'Select destination chain:'); + + this.display.section('📏 Route Configuration (Destination Chain)'); + const routeToken = await this.prompt.selectToken([], 'route'); + + this.display.section('💰 Reward Configuration (Source Chain)'); + const rewardToken = await this.prompt.selectToken([], 'reward'); + const { parsed: rewardAmount } = await this.prompt.inputAmount(rewardToken.symbol ?? 'tokens'); + + this.display.section('👤 Recipient Configuration'); + const recipientRaw = options.recipient ?? await this.prompt.inputAddress(destChain, 'recipient'); + const recipient = this.normalizer.normalize(recipientRaw as any, destChain.type); + + const rawKey = options.privateKey ?? this.config.getEvmPrivateKey() ?? ''; + const keyHandle = new KeyHandle(rawKey); + + // Derive sender address synchronously, then keep async key handle for publisher + let senderAddress: string; + const publishKeyHandle = new KeyHandle(rawKey); + keyHandle.use(key => { + // derive wallet address for display + senderAddress = key; // replace with getWalletAddress(sourceChain.type, key) + }); + + // Quote or fallback + let encodedRoute: string; + let sourcePortal = sourceChain.portalAddress!; + let proverAddress = sourceChain.proverAddress!; + + try { + this.display.spinner('Getting quote...'); + const quote = await this.quoteService.getQuote({ + source: sourceChain.id, + destination: destChain.id, + amount: rewardAmount, + funder: senderAddress!, + recipient: recipientRaw, + routeToken: routeToken.address, + rewardToken: rewardToken.address, + }); + this.display.succeed('Quote received'); + encodedRoute = quote.encodedRoute; + sourcePortal = this.normalizer.normalize(quote.sourcePortal as any, sourceChain.type); + proverAddress = this.normalizer.normalize(quote.prover as any, sourceChain.type); + } catch { + this.display.warn('Quote service unavailable — using manual configuration'); + const manual = await this.prompt.inputManualPortal(sourceChain); + encodedRoute = manual; // simplified — full manual fallback in production + } + + const reward = this.intentBuilder.buildReward({ + sourceChain, + creator: this.normalizer.normalize(senderAddress! as any, sourceChain.type), + prover: proverAddress, + rewardToken: this.normalizer.normalize(rewardToken.address as any, sourceChain.type), + rewardAmount, + }); + + // Display summary + confirm + const confirmed = await this.prompt.confirmPublish(); + if (!confirmed) throw new Error('Publication cancelled by user'); + + if (options.dryRun) { + this.display.warning('Dry run — not publishing'); + return; + } + + this.display.spinner('Publishing intent to blockchain...'); + const publisher = this.publisherFactory.create(sourceChain); + const result = await publisher.publish( + sourceChain.id, destChain.id, reward, encodedRoute, publishKeyHandle, sourcePortal, + ); + + if (!result.success) { + this.display.fail('Publishing failed'); + throw new Error(result.error); + } + + await this.intentStorage.save({ destination: destChain.id, sourceChainId: sourceChain.id, route: {} as any, reward }, result); + this.display.succeed('Intent published!'); + this.display.displayTransactionResult(result); + } + + @Option({ flags: '-s, --source ', description: 'Source chain name or ID' }) + parseSource(val: string) { return val; } + + @Option({ flags: '-d, --destination ', description: 'Destination chain name or ID' }) + parseDestination(val: string) { return val; } + + @Option({ flags: '-k, --private-key ', description: 'Private key override' }) + parsePrivateKey(val: string) { return val; } + + @Option({ flags: '-r, --rpc ', description: 'RPC URL override' }) + parseRpc(val: string) { return val; } + + @Option({ flags: '--recipient
', description: 'Recipient address on destination chain' }) + parseRecipient(val: string) { return val; } + + @Option({ flags: '--dry-run', description: 'Validate without broadcasting' }) + parseDryRun() { return true; } +} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/cli/commands/publish.command.ts +git commit -m "feat(cli): add PublishCommand as nestjs-commander injectable" +``` + +--- + +### Task 26: Create remaining commands + +**Files:** +- Create: `src/cli/commands/status.command.ts` +- Create: `src/cli/commands/config.command.ts` +- Create: `src/cli/commands/chains.command.ts` +- Create: `src/cli/commands/tokens.command.ts` + +**Step 1: Create `src/cli/commands/chains.command.ts`** + +```typescript +import { Command, CommandRunner } from 'nestjs-commander'; +import { Injectable } from '@nestjs/common'; +import { ChainsService } from '@/blockchain/chains.service'; +import { DisplayService } from '../services/display.service'; + +@Injectable() +@Command({ name: 'chains', description: 'List supported chains' }) +export class ChainsCommand extends CommandRunner { + constructor( + private readonly chains: ChainsService, + private readonly display: DisplayService, + ) { super(); } + + async run(): Promise { + this.display.displayChains(this.chains.listChains()); + } +} +``` + +**Step 2: Create `src/cli/commands/tokens.command.ts`** + +```typescript +import { Command, CommandRunner } from 'nestjs-commander'; +import { Injectable } from '@nestjs/common'; +import { DisplayService } from '../services/display.service'; +import { TOKENS } from '@/config/tokens.config'; + +@Injectable() +@Command({ name: 'tokens', description: 'List configured tokens' }) +export class TokensCommand extends CommandRunner { + constructor(private readonly display: DisplayService) { super(); } + + async run(): Promise { + this.display.displayTokens(Object.values(TOKENS)); + } +} +``` + +**Step 3: Create `src/cli/commands/status.command.ts`** + +Migrate `src/commands/status.ts` logic. Inject `StatusService` and `DisplayService`. Replace Commander option declarations with `@Option()` decorators. + +**Step 4: Create `src/cli/commands/config.command.ts`** + +Migrate `src/commands/config.ts` logic. Inject `ConfigService` and `PromptService`. + +**Step 5: Verify** + +```bash +pnpm typecheck +``` + +**Step 6: Commit** + +```bash +git add src/cli/commands/ +git commit -m "feat(cli): add chains, tokens, status, config commands as nestjs-commander injectables" +``` + +--- + +### Task 27: Create cli.module.ts + +**Files:** +- Create: `src/cli/cli.module.ts` + +**Step 1: Write the file** + +```typescript +import { Module } from '@nestjs/common'; +import { PromptService } from './services/prompt.service'; +import { DisplayService } from './services/display.service'; +import { PublishCommand } from './commands/publish.command'; +import { StatusCommand } from './commands/status.command'; +import { ConfigCommand } from './commands/config.command'; +import { ChainsCommand } from './commands/chains.command'; +import { TokensCommand } from './commands/tokens.command'; + +@Module({ + providers: [ + PromptService, + DisplayService, + PublishCommand, + StatusCommand, + ConfigCommand, + ChainsCommand, + TokensCommand, + ], +}) +export class CliModule {} +``` + +**Step 2: Verify** + +```bash +pnpm typecheck +``` + +**Step 3: Commit** + +```bash +git add src/cli/cli.module.ts +git commit -m "feat(cli): assemble CliModule (leaf module)" +``` + +--- + +## Phase 8: App Bootstrap + +### Task 28: Create app.module.ts and main.ts + +**Files:** +- Create: `src/app.module.ts` +- Create: `src/main.ts` + +**Step 1: Create `src/app.module.ts`** + +```typescript +import { Module } from '@nestjs/common'; +import { ConfigModule } from './config/config.module'; +import { BlockchainModule } from './blockchain/blockchain.module'; +import { IntentModule } from './intent/intent.module'; +import { QuoteModule } from './quote/quote.module'; +import { StatusModule } from './status/status.module'; +import { CliModule } from './cli/cli.module'; + +@Module({ + imports: [ + ConfigModule, + BlockchainModule, + IntentModule, + QuoteModule, + StatusModule, + CliModule, + ], +}) +export class AppModule {} +``` + +**Step 2: Create `src/main.ts`** + +```typescript +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { CommandFactory } from 'nestjs-commander'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const majorVersion = parseInt(process.version.slice(1).split('.')[0], 10); + if (majorVersion < 18) { + console.error(`Node.js >= 18 required. Current: ${process.version}`); + process.exit(1); + } + + await CommandFactory.run(AppModule, { + logger: false, + errorHandler: (err) => { + console.error(err.message); + if (process.env['DEBUG']) console.error(err.stack); + process.exit(1); + }, + }); +} + +bootstrap(); +``` + +**Step 3: Update `package.json` scripts** + +```json +"dev": "tsx -r tsconfig-paths/register src/main.ts", +"dev:testnet": "NODE_CHAINS_ENV=development tsx -r tsconfig-paths/register src/main.ts", +"start": "node -r tsconfig-paths/register dist/main.js" +``` + +**Step 4: Verify full build** + +```bash +pnpm build +``` +Expected: Clean compile, `dist/` populated. + +**Step 5: Smoke test** + +```bash +pnpm dev chains +``` +Expected: Table of supported chains printed. + +```bash +pnpm dev tokens +``` +Expected: Table of tokens printed. + +**Step 6: Commit** + +```bash +git add src/app.module.ts src/main.ts package.json +git commit -m "feat: bootstrap NestJS application with CommandFactory" +``` + +--- + +### Task 29: Remove old source files + +Once the new structure is verified working, remove the old files to avoid confusion. + +**Files to delete:** +- `src/index.ts` (replaced by `src/main.ts`) +- `src/core/` (replaced by `src/shared/` + moved to `src/blockchain/`) +- `src/commands/` (replaced by `src/cli/commands/`) +- `src/builders/` (replaced by `src/intent/intent-builder.service.ts`) +- `src/utils/logger.ts` (replaced by `src/cli/services/display.service.ts`) +- `src/commons/` if fully migrated + +**Step 1: Delete old directories** + +```bash +rm -rf src/index.ts src/core/ src/commands/ src/builders/ src/utils/logger.ts +``` + +**Step 2: Verify clean build** + +```bash +pnpm build +``` +Expected: No errors. + +**Step 3: Full smoke test** + +```bash +pnpm dev chains +pnpm dev tokens +pnpm dev publish --dry-run +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: remove old src/core, src/commands, src/builders, src/index.ts" +``` + +--- + +### Task 30: Update tsconfig.json + +**Files:** +- Modify: `tsconfig.json` + +**Step 1:** Update `exclude` to remove `src/scripts` if moved, add `src/shared` to include: + +```json +{ + "compilerOptions": { + "paths": { + "@/*": ["src/*"] + } + }, + "exclude": ["node_modules", "dist", "tests"] +} +``` + +**Step 2: Final full verification** + +```bash +pnpm build && pnpm dev chains && pnpm dev tokens +``` + +**Step 3: Commit** + +```bash +git add tsconfig.json +git commit -m "chore: update tsconfig paths for new directory structure" +``` + +--- + +## Summary + +| Phase | Tasks | Key Deliverables | +|-------|-------|-----------------| +| 1: Foundation | 1–4 | NestJS deps, `shared/` types + security + errors | +| 2: ConfigModule | 5–7 | Typed config with Zod, configurable deadline + dAppID | +| 3: BlockchainModule | 8–18 | ChainRegistry with `onModuleInit`, all chain logic co-located, `useAsync`, per-call TronWeb, `RpcService` | +| 4: QuoteModule | 19 | Injectable `QuoteService` | +| 5: IntentModule | 20–21 | Pure `IntentBuilder`, `IntentStorage` persistence | +| 6: StatusModule | 22 | Multi-chain `StatusService` | +| 7: CliModule | 23–27 | `PromptService`, `DisplayService`, all commands | +| 8: Bootstrap | 28–30 | `AppModule`, `main.ts`, cleanup | + +**Issues resolved:** All 12 from `ARCHITECTURE.md` diff --git a/eslint.config.js b/eslint.config.js index 39345d4..0a1f5c4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,7 +21,7 @@ module.exports = defineConfig([ sourceType: 'module', parserOptions: { - project: 'tsconfig.json', + projectService: true, tsconfigRootDir: __dirname, }, @@ -40,9 +40,19 @@ module.exports = defineConfig([ rules: { '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowExpressions: true, + allowTypedFunctionExpressions: true, + }, + ], '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/require-await': 'error', + 'no-console': ['error', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-unsafe-assignment': 'warn', '@typescript-eslint/no-unused-vars': [ 'error', @@ -66,7 +76,13 @@ module.exports = defineConfig([ '@typescript-eslint/member-ordering': 'off', }, }, - globalIgnores(['**/.eslintrc.js']), + globalIgnores(['**/.eslintrc.js', 'src/scripts/**']), + { + files: ['src/cli/**/*.ts'], + rules: { + 'no-console': 'off', + }, + }, { files: ['**/*.ts', '**/*.tsx'], diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 4591fc1..0000000 --- a/jest.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/?(*.)+(spec|test).ts'], - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.d.ts', - '!src/index.ts', - '!src/scripts/**/*', - ], - transform: { - '^.+\\.ts$': ['ts-jest', { tsconfig: 'tests/tsconfig.json' }] - }, - moduleNameMapper: { - '^@/(.*)$': '/src/$1' - } -}; \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..15ae839 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,30 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: ['**/tests/**/*.test.ts'], + testPathIgnorePatterns: ['/node_modules/', '/tests/e2e/'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/index.ts', + '!src/main.ts', + '!src/**/*.d.ts', + '!src/**/*.module.ts', + '!src/scripts/**', + ], + coverageThreshold: { + global: { branches: 15, functions: 20, lines: 25, statements: 25 }, + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^ora$': '/tests/__mocks__/ora.ts', + }, + setupFilesAfterEnv: ['/tests/setup/register-chain-handlers.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: 'tests/tsconfig.json' }], + }, +}; + +export default config; diff --git a/jest.e2e.config.ts b/jest.e2e.config.ts new file mode 100644 index 0000000..087fd17 --- /dev/null +++ b/jest.e2e.config.ts @@ -0,0 +1,19 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/tests/e2e/**/*.e2e.test.ts'], + globalSetup: './tests/e2e/setup/global-setup.ts', + globalTeardown: './tests/e2e/setup/global-teardown.ts', + testTimeout: 120_000, // 2 minutes — fork startup + transaction confirmation + maxWorkers: 1, // E2E tests must be serial (shared Anvil state) + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: 'tests/tsconfig.json' }], + }, +}; + +export default config; diff --git a/package.json b/package.json index a3335eb..30ead52 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,36 @@ { "name": "eco-routes-cli", - "version": "1.0.0", + "version": "1.0.1", "description": "CLI tool for publishing intents to EVM, TVM, and SVM chains", - "main": "dist/index.js", + "main": "bundle/index.js", "bin": { - "eco-routes-cli": "./dist/index.js" + "eco-routes-cli": "./bundle/index.js" }, + "files": [ + "bundle", + "README.md", + "LICENSE" + ], "scripts": { - "build": "tsc", - "dev": "tsx -r tsconfig-paths/register src/index.ts", - "dev:testnet": "NODE_CHAINS_ENV=development tsx -r tsconfig-paths/register src/index.ts", - "start": "node -r tsconfig-paths/register dist/index.js", - "clean": "rm -rf dist", + "build": "tsc --noEmit && ncc build src/main.ts -o bundle --no-cache", + "dev": "ts-node --transpile-only -r tsconfig-paths/register src/main.ts", + "dev:testnet": "NODE_CHAINS_ENV=development ts-node --transpile-only -r tsconfig-paths/register src/main.ts", + "start": "node bundle/index.js", + "clean": "rm -rf dist bundle", "test": "jest", + "test:unit": "jest --testPathPattern='tests/(core|config|blockchain)'", + "test:integration": "jest --testPathPattern='tests/integration'", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:e2e": "jest --config jest.e2e.config.ts", + "test:e2e:ci": "jest --config jest.e2e.config.ts --forceExit", "lint": "eslint src tests --ext .ts", "lint:fix": "eslint src tests --ext .ts --fix", "format": "prettier --write src tests", "format:check": "prettier --check src tests", "typecheck": "tsc --noEmit", - "prepare": "husky" + "prepare": "husky", + "docs": "typedoc" }, "keywords": [ "blockchain", @@ -33,37 +43,46 @@ "author": "Eco Protocol", "license": "MIT", "dependencies": { - "@coral-xyz/anchor": "^0.31.1", + "@coral-xyz/anchor": "^0.32.1", + "@nestjs/common": "^11.1.14", + "@nestjs/config": "^4.0.3", + "@nestjs/core": "^11.1.14", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.91.8", "chalk": "^4.1.2", "cli-table3": "^0.6.5", - "commander": "^12.1.0", "dotenv": "^16.4.5", "inquirer": "^9.3.7", + "nest-commander": "^3.20.1", "ora": "^8.2.0", - "tronweb": "^6.0.0", - "viem": "~2.40.1" + "reflect-metadata": "^0.2.2", + "tronweb": "^6.2.0", + "viem": "^2.40.1", + "zod": "^4.3.6" }, "devDependencies": { + "@changesets/cli": "^2.29.8", "@types/bn.js": "^5.2.0", "@types/inquirer": "^9.0.7", "@types/jest": "^30.0.0", "@types/node": "^20.16.16", - "@typescript-eslint/eslint-plugin": "^8.43.0", - "@typescript-eslint/parser": "^8.43.0", - "eslint": "^9.35.0", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "@vercel/ncc": "^0.38.4", + "eslint": "^10.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-simple-import-sort": "^12.1.1", + "globals": "^17.3.0", "husky": "^9.1.7", - "jest": "^30.1.3", + "jest": "^30.2.0", "lint-staged": "^16.1.6", "prettier": "^3.6.2", - "ts-jest": "^29.4.1", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", + "typedoc": "^0.28.17", "typescript": "^5.7.2" }, "lint-staged": { @@ -71,5 +90,26 @@ "eslint --fix", "prettier --write" ] + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, + "pnpm": { + "overrides": { + "axios": ">=1.13.5", + "minimatch": ">=10.2.1", + "test-exclude": "8.0.0", + "bn.js": "5.2.3" + }, + "auditConfig": { + "ignoreCves": [ + "GHSA-3gc7-fjrx-p6mg", + "CVE-2025-3194", + "GHSA-2g4f-4pwh-qvx6", + "CVE-2025-69873", + "GHSA-378v-28hj-76wf" + ] + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6765cac..6f6b2d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,13 +4,28 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + axios: '>=1.13.5' + minimatch: '>=10.2.1' + test-exclude: 8.0.0 + bn.js: 5.2.3 + importers: .: dependencies: '@coral-xyz/anchor': - specifier: ^0.31.1 - version: 0.31.1(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) + specifier: ^0.32.1 + version: 0.32.1(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) + '@nestjs/common': + specifier: ^11.1.14 + version: 11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.1.14 + version: 11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@solana/spl-token': specifier: ^0.4.14 version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) @@ -23,25 +38,34 @@ importers: cli-table3: specifier: ^0.6.5 version: 0.6.5 - commander: - specifier: ^12.1.0 - version: 12.1.0 dotenv: specifier: ^16.4.5 version: 16.6.1 inquirer: specifier: ^9.3.7 version: 9.3.7 + nest-commander: + specifier: ^3.20.1 + version: 3.20.1(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@types/inquirer@9.0.9)(@types/node@20.19.11)(typescript@5.9.2) ora: specifier: ^8.2.0 version: 8.2.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 tronweb: - specifier: ^6.0.0 - version: 6.0.4(bufferutil@4.0.9)(utf-8-validate@5.0.10) + specifier: ^6.2.0 + version: 6.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) viem: - specifier: ~2.40.1 - version: 2.40.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) + specifier: ^2.40.1 + version: 2.40.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@4.3.6) + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: + '@changesets/cli': + specifier: ^2.29.8 + version: 2.29.8(@types/node@20.19.11) '@types/bn.js': specifier: ^5.2.0 version: 5.2.0 @@ -55,29 +79,35 @@ importers: specifier: ^20.16.16 version: 20.19.11 '@typescript-eslint/eslint-plugin': - specifier: ^8.43.0 - version: 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0)(typescript@5.9.2) + specifier: ^8.56.0 + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.0)(typescript@5.9.2))(eslint@10.0.0)(typescript@5.9.2) '@typescript-eslint/parser': - specifier: ^8.43.0 - version: 8.43.0(eslint@9.35.0)(typescript@5.9.2) + specifier: ^8.56.0 + version: 8.56.0(eslint@10.0.0)(typescript@5.9.2) + '@vercel/ncc': + specifier: ^0.38.4 + version: 0.38.4 eslint: - specifier: ^9.35.0 - version: 9.35.0 + specifier: ^10.0.0 + version: 10.0.0 eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.35.0) + version: 9.1.2(eslint@10.0.0) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.5.4(eslint-config-prettier@9.1.2(eslint@9.35.0))(eslint@9.35.0)(prettier@3.6.2) + version: 5.5.4(eslint-config-prettier@9.1.2(eslint@10.0.0))(eslint@10.0.0)(prettier@3.6.2) eslint-plugin-simple-import-sort: specifier: ^12.1.1 - version: 12.1.1(eslint@9.35.0) + version: 12.1.1(eslint@10.0.0) + globals: + specifier: ^17.3.0 + version: 17.3.0 husky: specifier: ^9.1.7 version: 9.1.7 jest: - specifier: ^30.1.3 - version: 30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) + specifier: ^30.2.0 + version: 30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) lint-staged: specifier: ^16.1.6 version: 16.1.6 @@ -85,8 +115,8 @@ importers: specifier: ^3.6.2 version: 3.6.2 ts-jest: - specifier: ^29.4.1 - version: 29.4.1(@babel/core@7.28.4)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.4))(jest-util@30.0.5)(jest@30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)))(typescript@5.9.2) + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)))(typescript@5.9.2) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.11)(typescript@5.9.2) @@ -96,6 +126,9 @@ importers: tsx: specifier: ^4.20.5 version: 4.20.5 + typedoc: + specifier: ^0.28.17 + version: 0.28.17(typescript@5.9.2) typescript: specifier: ^5.7.2 version: 5.9.2 @@ -105,65 +138,65 @@ packages: '@adraffy/ens-normalize@1.10.1': resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} - '@adraffy/ens-normalize@1.11.0': - resolution: {integrity: sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg==} + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.4': - resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.4': - resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.4': - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true @@ -188,8 +221,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -204,8 +237,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -252,8 +285,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -266,21 +299,79 @@ packages: resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.4': - resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.4': - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + + '@changesets/apply-release-plan@7.0.14': + resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} + + '@changesets/assemble-release-plan@6.0.9': + resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.29.8': + resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==} + hasBin: true + + '@changesets/config@3.1.2': + resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + + '@changesets/get-release-plan@4.0.14': + resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.2': + resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.6': + resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -289,8 +380,8 @@ packages: resolution: {integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ==} engines: {node: '>=10'} - '@coral-xyz/anchor@0.31.1': - resolution: {integrity: sha512-QUqpoEK+gi2S6nlYc2atgT2r41TT3caWr/cPUEL8n8Md9437trZ68STknq897b82p5mW0XrTBNOzRbmIRJtfsA==} + '@coral-xyz/anchor@0.32.1': + resolution: {integrity: sha512-zAyxFtfeje2FbMA1wzgcdVs7Hng/MijPKpRijoySPCicnvcTQs/+dnPZ/cR+LcXM9v9UYSyW81uRNYZtN5G4yg==} engines: {node: '>=17'} '@coral-xyz/borsh@0.31.1': @@ -303,11 +394,11 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -468,43 +559,49 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.8.0': - resolution: {integrity: sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.0': - resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.1': + resolution: {integrity: sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.3.1': - resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.2': + resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.15.2': - resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.1.0': + resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.1': + resolution: {integrity: sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/js@9.35.0': - resolution: {integrity: sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.6.0': + resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fig/complete-commander@3.2.0': + resolution: {integrity: sha512-1Holl3XtRiANVKURZwgpjCnPuV4RsHp+XC0MhgvyAX/avQwj7F2HUItYOvGi/bXjJCkEzgBZmVfCr0HBA+q+Bw==} + peerDependencies: + commander: ^11.1.0 - '@eslint/plugin-kit@0.3.5': - resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@gerrit0/mini-shiki@3.22.0': + resolution: {integrity: sha512-jMpciqEVUBKE1QwU64S4saNMzpsSza6diNCk4MWAeCxO2+LFi2FIFmL2S0VDLzEJCxuvCbU783xi8Hp/gkM5CQ==} + + '@golevelup/nestjs-discovery@5.0.0': + resolution: {integrity: sha512-NaIWLCLI+XvneUK05LH2idHLmLNITYT88YnpOuUQmllKtiJNIS3woSt7QXrMZ5k3qUWuZpehEVz1JtlX4I1KyA==} + peerDependencies: + '@nestjs/common': ^11.0.20 + '@nestjs/core': ^11.0.20 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -522,6 +619,15 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/figures@1.0.13': resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==} engines: {node: '>=18'} @@ -538,12 +644,12 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jest/console@30.1.2': - resolution: {integrity: sha512-BGMAxj8VRmoD0MoA/jo9alMXSRoqW8KPeqOfEo1ncxnRLatTBCpRoOwlwlEMdudp68Q6WSGwYrrLtTGOh8fLzw==} + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.1.3': - resolution: {integrity: sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==} + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -555,36 +661,36 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/environment@30.1.2': - resolution: {integrity: sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==} + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect-utils@30.1.2': - resolution: {integrity: sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==} + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect@30.1.2': - resolution: {integrity: sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==} + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/fake-timers@30.1.2': - resolution: {integrity: sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==} + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/get-type@30.1.0': resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/globals@30.1.2': - resolution: {integrity: sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==} + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/pattern@30.0.1': resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/reporters@30.1.3': - resolution: {integrity: sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==} + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -596,28 +702,28 @@ packages: resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/snapshot-utils@30.1.2': - resolution: {integrity: sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==} + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-result@30.1.3': - resolution: {integrity: sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==} + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-sequencer@30.1.3': - resolution: {integrity: sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==} + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/transform@30.1.2': - resolution: {integrity: sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==} + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/types@30.0.5': - resolution: {integrity: sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==} + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jridgewell/gen-mapping@0.3.13': @@ -633,15 +739,62 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.30': - resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs/common@11.1.14': + resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.1.14': + resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -684,6 +837,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -710,8 +868,23 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} - '@sinclair/typebox@0.34.41': - resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} + '@shikijs/engine-oniguruma@3.22.0': + resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==} + + '@shikijs/langs@3.22.0': + resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==} + + '@shikijs/themes@3.22.0': + resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==} + + '@shikijs/types@3.22.0': + resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -807,6 +980,13 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -819,8 +999,8 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.0': - resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -840,9 +1020,15 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/inquirer@9.0.9': resolution: {integrity: sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==} @@ -876,6 +1062,9 @@ packages: '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -888,66 +1077,66 @@ packages: '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.43.0': - resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.43.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.43.0': - resolution: {integrity: sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==} + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.43.0': - resolution: {integrity: sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==} + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.43.0': - resolution: {integrity: sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==} + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.43.0': - resolution: {integrity: sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==} + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.43.0': - resolution: {integrity: sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.43.0': - resolution: {integrity: sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.43.0': - resolution: {integrity: sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.43.0': - resolution: {integrity: sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==} + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.43.0': - resolution: {integrity: sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==} + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -1048,6 +1237,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/ncc@0.38.4': + resolution: {integrity: sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==} + hasBin: true + abitype@1.1.0: resolution: {integrity: sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A==} peerDependencies: @@ -1073,6 +1266,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + aes-js@4.0.0-beta.5: resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} @@ -1083,6 +1281,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1099,6 +1301,10 @@ packages: resolution: {integrity: sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==} engines: {node: '>=12'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1111,6 +1317,10 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1124,24 +1334,28 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.11.0: - resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} - babel-jest@30.1.2: - resolution: {integrity: sha512-IQCus1rt9kaSh7PQxLYRY5NmkNrNlU2TpabzwV7T2jljnpdHOcmnYYv8QmE04Li4S3a2Lj8/yXyET5pBarPr6g==} + babel-jest@30.2.0: + resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: - '@babel/core': ^7.11.0 + '@babel/core': ^7.11.0 || ^8.0.0-0 babel-plugin-istanbul@7.0.1: resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} engines: {node: '>=12'} - babel-plugin-jest-hoist@30.0.1: - resolution: {integrity: sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==} + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} babel-preset-current-node-syntax@1.2.0: @@ -1149,14 +1363,15 @@ packages: peerDependencies: '@babel/core': ^7.0.0 || ^8.0.0-0 - babel-preset-jest@30.0.1: - resolution: {integrity: sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==} + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: - '@babel/core': ^7.11.0 + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} + engines: {node: 20 || >=22} base-x@3.0.11: resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} @@ -1164,6 +1379,15 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + bigint-buffer@1.1.5: resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==} engines: {node: '>= 10.0.0'} @@ -1171,30 +1395,31 @@ packages: bignumber.js@9.1.2: resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bn.js@5.2.2: - resolution: {integrity: sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==} + bn.js@5.2.3: + resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.2: + resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.25.4: - resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1241,8 +1466,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001741: - resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} + caniuse-lite@1.0.30001770: + resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1252,6 +1477,10 @@ packages: resolution: {integrity: sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -1259,12 +1488,19 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - ci-info@4.3.0: - resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} - cjs-module-lexer@2.1.0: - resolution: {integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} @@ -1286,6 +1522,10 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1302,8 +1542,8 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1319,6 +1559,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -1330,12 +1574,22 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -1355,8 +1609,17 @@ packages: supports-color: optional: true - dedent@1.7.0: - resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: @@ -1381,6 +1644,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1389,10 +1656,22 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1400,8 +1679,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.214: - resolution: {integrity: sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==} + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -1416,12 +1695,20 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} @@ -1454,6 +1741,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -1487,21 +1778,21 @@ packages: peerDependencies: eslint: '>=5.0.0' - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.0: + resolution: {integrity: sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.0: + resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.35.0: - resolution: {integrity: sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@10.0.0: + resolution: {integrity: sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -1509,17 +1800,17 @@ packages: jiti: optional: true - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.1.0: + resolution: {integrity: sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -1555,10 +1846,13 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} - expect@30.1.2: - resolution: {integrity: sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==} + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} @@ -1583,22 +1877,42 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-stable-stringify@1.0.0: resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} fastestsmallesttextencoderdecoder@1.0.22: resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -1634,12 +1948,17 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -1688,18 +2007,23 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + globals@17.3.0: + resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + google-protobuf@3.21.4: resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} @@ -1710,9 +2034,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -1737,6 +2058,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1753,6 +2078,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1777,13 +2106,13 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} + engines: {node: '>=12.0.0'} + inquirer@9.3.7: resolution: {integrity: sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==} engines: {node: '>=18'} @@ -1831,6 +2160,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -1843,6 +2176,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1876,6 +2213,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -1884,16 +2225,16 @@ packages: engines: {node: '>=8'} hasBin: true - jest-changed-files@30.0.5: - resolution: {integrity: sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==} + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-circus@30.1.3: - resolution: {integrity: sha512-Yf3dnhRON2GJT4RYzM89t/EXIWNxKTpWTL9BfF3+geFetWP4XSvJjiU1vrWplOiUkmq8cHLiwuhz+XuUp9DscA==} + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.1.3: - resolution: {integrity: sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==} + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -1902,8 +2243,8 @@ packages: node-notifier: optional: true - jest-config@30.1.3: - resolution: {integrity: sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==} + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@types/node': '*' @@ -1917,40 +2258,40 @@ packages: ts-node: optional: true - jest-diff@30.1.2: - resolution: {integrity: sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==} + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-docblock@30.0.1: - resolution: {integrity: sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==} + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-each@30.1.0: - resolution: {integrity: sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==} + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-node@30.1.2: - resolution: {integrity: sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==} + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-haste-map@30.1.0: - resolution: {integrity: sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==} + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-leak-detector@30.1.0: - resolution: {integrity: sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==} + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-matcher-utils@30.1.2: - resolution: {integrity: sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==} + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-message-util@30.1.0: - resolution: {integrity: sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==} + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-mock@30.0.5: - resolution: {integrity: sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==} + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-pnp-resolver@1.2.3: @@ -1966,44 +2307,44 @@ packages: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.1.3: - resolution: {integrity: sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==} + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve@30.1.3: - resolution: {integrity: sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==} + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runner@30.1.3: - resolution: {integrity: sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==} + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runtime@30.1.3: - resolution: {integrity: sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==} + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-snapshot@30.1.2: - resolution: {integrity: sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==} + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-util@30.0.5: - resolution: {integrity: sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==} + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-validate@30.1.0: - resolution: {integrity: sha512-7P3ZlCFW/vhfQ8pE7zW6Oi4EzvuB4sgR72Q1INfW9m0FGo0GADYlPwIkf4CyPq7wq85g+kPMtPOHNAdWHeBOaA==} + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-watcher@30.1.3: - resolution: {integrity: sha512-6jQUZCP1BTL2gvG9E4YF06Ytq4yMb4If6YoQGRR6PpjtqOXSP3sKe2kqwB6SQ+H9DezOfZaSLnmka1NtGm3fCQ==} + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-worker@30.1.0: - resolution: {integrity: sha512-uvWcSjlwAAgIu133Tt77A05H7RIk3Ho8tZL50bQM2AkvLdluw9NG48lRCl3Dt+MOH719n/0nnb5YxUwcuJiKRA==} + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.1.3: - resolution: {integrity: sha512-Ry+p2+NLk6u8Agh5yVqELfUJvRfV51hhVBRIB5yZPY7mU0DGBmOuFG5GebZbMbm86cdQNK0fhJuDX8/1YorISQ==} + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -2015,12 +2356,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.1.0: @@ -2048,6 +2389,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2066,6 +2410,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lint-staged@16.1.6: resolution: {integrity: sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==} engines: {node: '>=20.17'} @@ -2075,6 +2422,10 @@ packages: resolution: {integrity: sha512-0aeh5HHHgmq1KRdMMDHfhMWQmIT/m7nRDTlxlFqni2Sp0had9baqsjJRvDGdlvgd6NmPE0nPloOipiQJGFtTHQ==} engines: {node: '>=20.0.0'} + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2086,8 +2437,11 @@ packages: lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -2104,9 +2458,16 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2117,10 +2478,17 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2148,23 +2516,27 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mute-stream@1.0.0: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -2173,8 +2545,8 @@ packages: resolution: {integrity: sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==} engines: {node: '>=20.17'} - napi-postinstall@0.3.3: - resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==} + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} hasBin: true @@ -2184,6 +2556,13 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nest-commander@3.20.1: + resolution: {integrity: sha512-LRI7z6UlWy2vWyQR0PYnAXsaRyJvpfiuvOCmx2jk2kLXJH9+y/omPDl9NE3fq4WMaE0/AhviuUjA12eC/zDqXw==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@types/inquirer': ^8.1.3 + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2200,8 +2579,8 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.20: - resolution: {integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -2211,9 +2590,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -2238,6 +2614,9 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + ox@0.9.6: resolution: {integrity: sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg==} peerDependencies: @@ -2246,6 +2625,10 @@ packages: typescript: optional: true + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -2262,6 +2645,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -2269,6 +2656,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} @@ -2284,10 +2674,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2296,6 +2682,17 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2312,6 +2709,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2328,18 +2729,27 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true - pretty-format@30.0.5: - resolution: {integrity: sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==} + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2347,16 +2757,26 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -2397,6 +2817,10 @@ packages: rpc-websockets@9.1.3: resolution: {integrity: sha512-I+kNjW0udB4Fetr3vvtRuYZJS0PcSPyyvBcH5sDdoV8DFs5E4W2pTr7aiMlKfPxANTClP9RlqCPolj9dd5MsEA==} + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + run-async@3.0.0: resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} engines: {node: '>=0.12.0'} @@ -2422,8 +2846,8 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -2461,6 +2885,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2509,6 +2936,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2525,6 +2956,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + superstruct@0.15.5: resolution: {integrity: sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ==} @@ -2544,13 +2979,28 @@ packages: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + test-exclude@8.0.0: + resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==} + engines: {node: 20 || >=22} + text-encoding-utf-8@1.0.2: resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -2562,23 +3012,27 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tronweb@6.0.4: - resolution: {integrity: sha512-+9Nc7H4FYVh2DcOnQG93WLm3UdlHSf9W+GXkfrXI77oLjTB1cptROJDKRSSxQBiOAyjjAJOOTuYDzlAkaLT85w==} + tronweb@6.2.0: + resolution: {integrity: sha512-09kyW6mqiFuSYXkR35ndxCeNF5rW1O18hKAClCLtVHP2xBFPYSGx3lDYC2hRKcuLiq6iLPxOVCrhzoKNGlFuQQ==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' - ts-jest@29.4.1: - resolution: {integrity: sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==} + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -2649,27 +3103,49 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + typedoc@0.28.17: + resolution: {integrity: sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} hasBin: true + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -2695,8 +3171,8 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} - validator@13.12.0: - resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + validator@13.15.23: + resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} engines: {node: '>= 0.10'} viem@2.40.4: @@ -2747,9 +3223,6 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - write-file-atomic@5.0.1: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -2822,175 +3295,178 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@adraffy/ens-normalize@1.10.1': {} - '@adraffy/ens-normalize@1.11.0': {} + '@adraffy/ens-normalize@1.11.1': {} - '@babel/code-frame@7.27.1': + '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.4': {} + '@babel/compat-data@7.29.0': {} - '@babel/core@7.28.4': + '@babel/core@7.29.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/generator@7.28.3': + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.28.4 + '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.4 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.4': + '@babel/helpers@7.28.6': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 - '@babel/parser@7.28.4': + '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.29.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.4)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.4)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.4)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.4)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.4)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.4)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.4)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.4)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.4)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.4)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.4)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.4)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.4)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.26.10': dependencies: @@ -2998,43 +3474,189 @@ snapshots: '@babel/runtime@7.28.3': {} - '@babel/template@7.27.2': + '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 - '@babel/traverse@7.28.4': + '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - debug: 4.4.1 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.28.4': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 '@bcoe/v8-coverage@0.2.3': {} + '@borewit/text-codec@0.2.1': {} + + '@changesets/apply-release-plan@7.0.14': + dependencies: + '@changesets/config': 3.1.2 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.4 + + '@changesets/assemble-release-plan@6.0.9': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.4 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.29.8(@types/node@20.19.11)': + dependencies: + '@changesets/apply-release-plan': 7.0.14 + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.2 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.14 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@20.19.11) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + ci-info: 3.9.0 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + p-limit: 2.3.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.4 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.3': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.4 + + '@changesets/get-release-plan@4.0.14': + dependencies: + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/config': 3.1.2 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.2': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.1.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.6': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.2 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.3 + prettier: 2.8.8 + '@colors/colors@1.5.0': optional: true '@coral-xyz/anchor-errors@0.31.1': {} - '@coral-xyz/anchor@0.31.1(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)': + '@coral-xyz/anchor@0.32.1(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)': dependencies: '@coral-xyz/anchor-errors': 0.31.1 '@coral-xyz/borsh': 0.31.1(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)) '@noble/hashes': 1.8.0 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) - bn.js: 5.2.2 + bn.js: 5.2.3 bs58: 4.0.1 buffer-layout: 1.2.2 camelcase: 6.3.0 @@ -3052,20 +3674,20 @@ snapshots: '@coral-xyz/borsh@0.31.1(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10))': dependencies: '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) - bn.js: 5.2.2 + bn.js: 5.2.3 buffer-layout: 1.2.2 '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@emnapi/core@1.5.0': + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -3153,49 +3775,54 @@ snapshots: '@esbuild/win32-x64@0.25.9': optional: true - '@eslint-community/eslint-utils@4.8.0(eslint@9.35.0)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.0)': dependencies: - eslint: 9.35.0 + eslint: 10.0.0 eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.1': {} + '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.0': + '@eslint/config-array@0.23.1': dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.1 - minimatch: 3.1.2 + '@eslint/object-schema': 3.0.1 + debug: 4.4.3 + minimatch: 10.2.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.3.1': {} + '@eslint/config-helpers@0.5.2': + dependencies: + '@eslint/core': 1.1.0 - '@eslint/core@0.15.2': + '@eslint/core@1.1.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/object-schema@3.0.1': {} + + '@eslint/plugin-kit@0.6.0': dependencies: - ajv: 6.12.6 - debug: 4.4.1 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color + '@eslint/core': 1.1.0 + levn: 0.4.1 - '@eslint/js@9.35.0': {} + '@fig/complete-commander@3.2.0(commander@11.1.0)': + dependencies: + commander: 11.1.0 + prettier: 3.6.2 - '@eslint/object-schema@2.1.6': {} + '@gerrit0/mini-shiki@3.22.0': + dependencies: + '@shikijs/engine-oniguruma': 3.22.0 + '@shikijs/langs': 3.22.0 + '@shikijs/themes': 3.22.0 + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 - '@eslint/plugin-kit@0.3.5': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: - '@eslint/core': 0.15.2 - levn: 0.4.1 + '@nestjs/common': 11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + lodash: 4.17.23 '@humanfs/core@0.19.1': {} @@ -3208,13 +3835,20 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/external-editor@1.0.3(@types/node@20.19.11)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 20.19.11 + '@inquirer/figures@1.0.13': {} '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -3224,49 +3858,49 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.1 + js-yaml: 3.14.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} - '@jest/console@30.1.2': + '@jest/console@30.2.0': dependencies: - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 '@types/node': 20.19.11 chalk: 4.1.2 - jest-message-util: 30.1.0 - jest-util: 30.0.5 + jest-message-util: 30.2.0 + jest-util: 30.2.0 slash: 3.0.0 - '@jest/core@30.1.3(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2))': + '@jest/core@30.2.0(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2))': dependencies: - '@jest/console': 30.1.2 + '@jest/console': 30.2.0 '@jest/pattern': 30.0.1 - '@jest/reporters': 30.1.3 - '@jest/test-result': 30.1.3 - '@jest/transform': 30.1.2 - '@jest/types': 30.0.5 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.19.11 ansi-escapes: 4.3.2 chalk: 4.1.2 - ci-info: 4.3.0 + ci-info: 4.4.0 exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-changed-files: 30.0.5 - jest-config: 30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) - jest-haste-map: 30.1.0 - jest-message-util: 30.1.0 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.1.3 - jest-resolve-dependencies: 30.1.3 - jest-runner: 30.1.3 - jest-runtime: 30.1.3 - jest-snapshot: 30.1.2 - jest-util: 30.0.5 - jest-validate: 30.1.0 - jest-watcher: 30.1.3 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 micromatch: 4.0.8 - pretty-format: 30.0.5 + pretty-format: 30.2.0 slash: 3.0.0 transitivePeerDependencies: - babel-plugin-macros @@ -3276,41 +3910,41 @@ snapshots: '@jest/diff-sequences@30.0.1': {} - '@jest/environment@30.1.2': + '@jest/environment@30.2.0': dependencies: - '@jest/fake-timers': 30.1.2 - '@jest/types': 30.0.5 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.19.11 - jest-mock: 30.0.5 + jest-mock: 30.2.0 - '@jest/expect-utils@30.1.2': + '@jest/expect-utils@30.2.0': dependencies: '@jest/get-type': 30.1.0 - '@jest/expect@30.1.2': + '@jest/expect@30.2.0': dependencies: - expect: 30.1.2 - jest-snapshot: 30.1.2 + expect: 30.2.0 + jest-snapshot: 30.2.0 transitivePeerDependencies: - supports-color - '@jest/fake-timers@30.1.2': + '@jest/fake-timers@30.2.0': dependencies: - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 '@sinonjs/fake-timers': 13.0.5 '@types/node': 20.19.11 - jest-message-util: 30.1.0 - jest-mock: 30.0.5 - jest-util: 30.0.5 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 '@jest/get-type@30.1.0': {} - '@jest/globals@30.1.2': + '@jest/globals@30.2.0': dependencies: - '@jest/environment': 30.1.2 - '@jest/expect': 30.1.2 - '@jest/types': 30.0.5 - jest-mock: 30.0.5 + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 transitivePeerDependencies: - supports-color @@ -3319,28 +3953,28 @@ snapshots: '@types/node': 20.19.11 jest-regex-util: 30.0.1 - '@jest/reporters@30.1.3': + '@jest/reporters@30.2.0': dependencies: '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 30.1.2 - '@jest/test-result': 30.1.3 - '@jest/transform': 30.1.2 - '@jest/types': 30.0.5 - '@jridgewell/trace-mapping': 0.3.30 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 '@types/node': 20.19.11 chalk: 4.1.2 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 exit-x: 0.2.2 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.2 istanbul-lib-instrument: 6.0.3 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - jest-message-util: 30.1.0 - jest-util: 30.0.5 - jest-worker: 30.1.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 slash: 3.0.0 string-length: 4.0.2 v8-to-istanbul: 9.3.0 @@ -3349,48 +3983,48 @@ snapshots: '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.41 + '@sinclair/typebox': 0.34.48 - '@jest/snapshot-utils@30.1.2': + '@jest/snapshot-utils@30.2.0': dependencies: - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 chalk: 4.1.2 graceful-fs: 4.2.11 natural-compare: 1.4.0 '@jest/source-map@30.0.1': dependencies: - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 - '@jest/test-result@30.1.3': + '@jest/test-result@30.2.0': dependencies: - '@jest/console': 30.1.2 - '@jest/types': 30.0.5 + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 - '@jest/test-sequencer@30.1.3': + '@jest/test-sequencer@30.2.0': dependencies: - '@jest/test-result': 30.1.3 + '@jest/test-result': 30.2.0 graceful-fs: 4.2.11 - jest-haste-map: 30.1.0 + jest-haste-map: 30.2.0 slash: 3.0.0 - '@jest/transform@30.1.2': + '@jest/transform@30.2.0': dependencies: - '@babel/core': 7.28.4 - '@jest/types': 30.0.5 - '@jridgewell/trace-mapping': 0.3.30 + '@babel/core': 7.29.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 7.0.1 chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 - jest-haste-map: 30.1.0 + jest-haste-map: 30.2.0 jest-regex-util: 30.0.1 - jest-util: 30.0.5 + jest-util: 30.2.0 micromatch: 4.0.8 pirates: 4.0.7 slash: 3.0.0 @@ -3398,31 +4032,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/types@30.0.5': + '@jest/types@30.2.0': dependencies: '@jest/pattern': 30.0.1 '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 '@types/node': 20.19.11 - '@types/yargs': 17.0.33 + '@types/yargs': 17.0.35 chalk: 4.1.2 '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.30': + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 @@ -3432,13 +4066,63 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/csprng@1.1.0': {} + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.28.3 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.28.3 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 - '@tybys/wasm-util': 0.10.0 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.3(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + '@noble/ciphers@1.3.0': {} '@noble/curves@1.2.0': @@ -3473,7 +4157,11 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 '@pkgjs/parseargs@0.11.0': optional: true @@ -3492,7 +4180,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.7 + '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -3506,7 +4194,27 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@sinclair/typebox@0.34.41': {} + '@shikijs/engine-oniguruma@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + + '@shikijs/themes@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + + '@shikijs/types@3.22.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sinclair/typebox@0.34.48': {} '@sinonjs/commons@3.0.1': dependencies: @@ -3521,7 +4229,7 @@ snapshots: '@solana/buffer-layout': 4.0.1 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) bigint-buffer: 1.1.5 - bignumber.js: 9.1.2 + bignumber.js: 9.3.1 transitivePeerDependencies: - bufferutil - encoding @@ -3582,7 +4290,7 @@ snapshots: '@solana/errors@2.0.0-rc.1(typescript@5.9.2)': dependencies: - chalk: 5.6.0 + chalk: 5.6.2 commander: 12.1.0 typescript: 5.9.2 @@ -3642,7 +4350,7 @@ snapshots: '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.9.2) agentkeepalive: 4.6.0 - bn.js: 5.2.2 + bn.js: 5.2.3 borsh: 0.7.0 bs58: 4.0.1 buffer: 6.0.3 @@ -3661,6 +4369,15 @@ snapshots: dependencies: tslib: 2.8.1 + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -3669,31 +4386,31 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.10.0': + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.29.0 '@types/bn.js@5.2.0': dependencies: @@ -3703,8 +4420,14 @@ snapshots: dependencies: '@types/node': 20.19.11 + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/inquirer@9.0.9': dependencies: '@types/through': 0.0.33 @@ -3722,8 +4445,8 @@ snapshots: '@types/jest@30.0.0': dependencies: - expect: 30.1.2 - pretty-format: 30.0.5 + expect: 30.2.0 + pretty-format: 30.2.0 '@types/json-schema@7.0.15': {} @@ -3743,6 +4466,8 @@ snapshots: dependencies: '@types/node': 20.19.11 + '@types/unist@3.0.3': {} + '@types/uuid@8.3.4': {} '@types/ws@7.4.7': @@ -3755,102 +4480,100 @@ snapshots: '@types/yargs-parser@21.0.3': {} - '@types/yargs@17.0.33': + '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0)(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.0)(typescript@5.9.2))(eslint@10.0.0)(typescript@5.9.2)': dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0)(typescript@5.9.2) - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/type-utils': 8.43.0(eslint@9.35.0)(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0)(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.43.0 - eslint: 9.35.0 - graphemer: 1.4.0 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.0(eslint@10.0.0)(typescript@5.9.2) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.0)(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 10.0.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.2) + ts-api-utils: 2.4.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2)': + '@typescript-eslint/parser@8.56.0(eslint@10.0.0)(typescript@5.9.2)': dependencies: - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.1 - eslint: 9.35.0 + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + eslint: 10.0.0 typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.43.0(typescript@5.9.2)': + '@typescript-eslint/project-service@8.56.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) - '@typescript-eslint/types': 8.43.0 - debug: 4.4.1 + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.2) + '@typescript-eslint/types': 8.56.0 + debug: 4.4.3 typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.43.0': + '@typescript-eslint/scope-manager@8.56.0': dependencies: - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/visitor-keys': 8.43.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/tsconfig-utils@8.43.0(typescript@5.9.2)': + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.2)': dependencies: typescript: 5.9.2 - '@typescript-eslint/type-utils@8.43.0(eslint@9.35.0)(typescript@5.9.2)': + '@typescript-eslint/type-utils@8.56.0(eslint@10.0.0)(typescript@5.9.2)': dependencies: - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0)(typescript@5.9.2) - debug: 4.4.1 - eslint: 9.35.0 - ts-api-utils: 2.1.0(typescript@5.9.2) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.0)(typescript@5.9.2) + debug: 4.4.3 + eslint: 10.0.0 + ts-api-utils: 2.4.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.43.0': {} + '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/typescript-estree@8.43.0(typescript@5.9.2)': + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/project-service': 8.43.0(typescript@5.9.2) - '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.1 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.2) + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.2) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 10.2.2 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.43.0(eslint@9.35.0)(typescript@5.9.2)': + '@typescript-eslint/utils@8.56.0(eslint@10.0.0)(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.8.0(eslint@9.35.0) - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - eslint: 9.35.0 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.2) + eslint: 10.0.0 typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.43.0': + '@typescript-eslint/visitor-keys@8.56.0': dependencies: - '@typescript-eslint/types': 8.43.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.0 '@ungap/structured-clone@1.3.0': {} @@ -3913,13 +4636,16 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - abitype@1.1.0(typescript@5.9.2): + '@vercel/ncc@0.38.4': {} + + abitype@1.1.0(typescript@5.9.2)(zod@4.3.6): optionalDependencies: typescript: 5.9.2 + zod: 4.3.6 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk@8.3.4: dependencies: @@ -3927,6 +4653,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + aes-js@4.0.0-beta.5: {} agentkeepalive@4.6.0: @@ -3940,6 +4668,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -3952,6 +4682,8 @@ snapshots: ansi-regex@6.2.0: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -3960,6 +4692,8 @@ snapshots: ansi-styles@6.2.1: {} + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -3973,23 +4707,25 @@ snapshots: argparse@2.0.1: {} + array-union@2.1.0: {} + asynckit@0.4.0: {} - axios@1.11.0: + axios@1.13.5: dependencies: follow-redirects: 1.15.11 - form-data: 4.0.4 + form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - babel-jest@30.1.2(@babel/core@7.28.4): + babel-jest@30.2.0(@babel/core@7.29.0): dependencies: - '@babel/core': 7.28.4 - '@jest/transform': 30.1.2 + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.0.1(@babel/core@7.28.4) + babel-preset-jest: 30.2.0(@babel/core@7.29.0) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -3998,46 +4734,44 @@ snapshots: babel-plugin-istanbul@7.0.1: dependencies: - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 6.0.3 - test-exclude: 6.0.0 + test-exclude: 8.0.0 transitivePeerDependencies: - supports-color - babel-plugin-jest-hoist@30.0.1: + babel-plugin-jest-hoist@30.2.0: dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 '@types/babel__core': 7.20.5 - babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.4) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.4) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.4) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.4) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.4) - - babel-preset-jest@30.0.1(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - babel-plugin-jest-hoist: 30.0.1 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) - - balanced-match@1.0.2: {} + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-jest@30.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + balanced-match@4.0.3: {} base-x@3.0.11: dependencies: @@ -4045,12 +4779,20 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.0: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + bigint-buffer@1.1.5: dependencies: bindings: 1.5.0 bignumber.js@9.1.2: {} + bignumber.js@9.3.1: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -4061,33 +4803,29 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bn.js@5.2.2: {} + bn.js@5.2.3: {} borsh@0.7.0: dependencies: - bn.js: 5.2.2 + bn.js: 5.2.3 bs58: 4.0.1 text-encoding-utf-8: 1.0.2 - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: + brace-expansion@5.0.2: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.3 braces@3.0.3: dependencies: fill-range: 7.1.1 - browserslist@4.25.4: + browserslist@4.28.1: dependencies: - caniuse-lite: 1.0.30001741 - electron-to-chromium: 1.5.214 - node-releases: 2.0.20 - update-browserslist-db: 1.1.3(browserslist@4.25.4) + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001770 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) bs-logger@0.2.6: dependencies: @@ -4131,7 +4869,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001741: {} + caniuse-lite@1.0.30001770: {} chalk@4.1.2: dependencies: @@ -4140,13 +4878,19 @@ snapshots: chalk@5.6.0: {} + chalk@5.6.2: {} + char-regex@1.0.2: {} chardet@0.7.0: {} - ci-info@4.3.0: {} + chardet@2.1.1: {} + + ci-info@3.9.0: {} + + ci-info@4.4.0: {} - cjs-module-lexer@2.1.0: {} + cjs-module-lexer@2.2.0: {} cli-cursor@3.1.0: dependencies: @@ -4169,6 +4913,8 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cli-width@3.0.0: {} + cli-width@4.1.0: {} cliui@8.0.1: @@ -4181,7 +4927,7 @@ snapshots: co@4.6.0: {} - collect-v8-coverage@1.0.2: {} + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: dependencies: @@ -4195,16 +4941,27 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@11.1.0: {} + commander@12.1.0: {} commander@14.0.0: {} commander@2.20.3: {} - concat-map@0.0.1: {} + consola@3.4.2: {} convert-source-map@2.0.0: {} + cosmiconfig@8.3.6(typescript@5.9.2): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.2 + create-require@1.1.1: {} cross-fetch@3.2.0: @@ -4223,7 +4980,11 @@ snapshots: dependencies: ms: 2.1.3 - dedent@1.7.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.1: {} deep-is@0.1.4: {} @@ -4237,12 +4998,24 @@ snapshots: delayed-stream@1.0.0: {} + detect-indent@6.1.0: {} + detect-newline@3.1.0: {} diff@4.0.2: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + dotenv@16.6.1: {} + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4251,7 +5024,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.214: {} + electron-to-chromium@1.5.286: {} emittery@0.13.1: {} @@ -4261,9 +5034,16 @@ snapshots: emoji-regex@9.2.2: {} + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@4.5.0: {} + environment@1.1.0: {} - error-ex@1.3.2: + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4319,60 +5099,60 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.2(eslint@9.35.0): + eslint-config-prettier@9.1.2(eslint@10.0.0): dependencies: - eslint: 9.35.0 + eslint: 10.0.0 - eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.2(eslint@9.35.0))(eslint@9.35.0)(prettier@3.6.2): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.2(eslint@10.0.0))(eslint@10.0.0)(prettier@3.6.2): dependencies: - eslint: 9.35.0 + eslint: 10.0.0 prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.11 optionalDependencies: - eslint-config-prettier: 9.1.2(eslint@9.35.0) + eslint-config-prettier: 9.1.2(eslint@10.0.0) - eslint-plugin-simple-import-sort@12.1.1(eslint@9.35.0): + eslint-plugin-simple-import-sort@12.1.1(eslint@10.0.0): dependencies: - eslint: 9.35.0 + eslint: 10.0.0 - eslint-scope@8.4.0: + eslint-scope@9.1.0: dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.0: {} - eslint@9.35.0: + eslint@10.0.0: dependencies: - '@eslint-community/eslint-utils': 4.8.0(eslint@9.35.0) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.3.1 - '@eslint/core': 0.15.2 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.35.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.1 + '@eslint/config-helpers': 0.5.2 + '@eslint/core': 1.1.0 + '@eslint/plugin-kit': 0.6.0 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 ajv: 6.12.6 - chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 + eslint-scope: 9.1.0 + eslint-visitor-keys: 5.0.0 + espree: 11.1.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -4382,22 +5162,21 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 10.2.2 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: - supports-color - espree@10.4.0: + espree@11.1.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.0 esprima@4.0.1: {} - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -4447,14 +5226,16 @@ snapshots: exit-x@0.2.2: {} - expect@30.1.2: + expect@30.2.0: dependencies: - '@jest/expect-utils': 30.1.2 + '@jest/expect-utils': 30.2.0 '@jest/get-type': 30.1.0 - jest-matcher-utils: 30.1.2 - jest-message-util: 30.1.0 - jest-mock: 30.0.5 - jest-util: 30.0.5 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + extendable-error@0.1.7: {} external-editor@3.1.0: dependencies: @@ -4480,11 +5261,13 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-stable-stringify@1.0.0: {} fastestsmallesttextencoderdecoder@1.0.22: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4492,10 +5275,27 @@ snapshots: dependencies: bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -4526,7 +5326,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.4: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -4534,7 +5334,17 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - fs.realpath@1.0.0: {} + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 fsevents@2.3.3: optional: true @@ -4581,25 +5391,31 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: + glob@10.5.0: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 + minimatch: 10.2.2 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@7.2.3: + glob@13.0.6: dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 + minimatch: 10.2.2 + minipass: 7.1.3 + path-scurry: 2.0.2 - globals@14.0.0: {} + globals@17.3.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 google-protobuf@3.21.4: {} @@ -4607,8 +5423,6 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -4632,6 +5446,8 @@ snapshots: html-escaper@2.0.2: {} + human-id@4.1.3: {} + human-signals@2.1.0: {} humanize-ms@1.2.1: @@ -4644,6 +5460,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -4662,13 +5482,28 @@ snapshots: imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} + inquirer@8.2.7(@types/node@20.19.11): + dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@20.19.11) + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + figures: 3.2.0 + lodash: 4.17.23 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' + inquirer@9.3.7: dependencies: '@inquirer/figures': 1.0.13 @@ -4710,12 +5545,18 @@ snapshots: is-stream@2.0.1: {} + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + is-unicode-supported@0.1.0: {} is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} + is-windows@1.0.2: {} + isexe@2.0.0: {} isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)): @@ -4730,11 +5571,11 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.28.4 - '@babel/parser': 7.28.4 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.1 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -4746,8 +5587,8 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.30 - debug: 4.4.1 + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -4757,6 +5598,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + iterare@1.2.1: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -4781,31 +5624,31 @@ snapshots: - bufferutil - utf-8-validate - jest-changed-files@30.0.5: + jest-changed-files@30.2.0: dependencies: execa: 5.1.1 - jest-util: 30.0.5 + jest-util: 30.2.0 p-limit: 3.1.0 - jest-circus@30.1.3: + jest-circus@30.2.0: dependencies: - '@jest/environment': 30.1.2 - '@jest/expect': 30.1.2 - '@jest/test-result': 30.1.3 - '@jest/types': 30.0.5 + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.19.11 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.0 + dedent: 1.7.1 is-generator-fn: 2.1.0 - jest-each: 30.1.0 - jest-matcher-utils: 30.1.2 - jest-message-util: 30.1.0 - jest-runtime: 30.1.3 - jest-snapshot: 30.1.2 - jest-util: 30.0.5 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 p-limit: 3.1.0 - pretty-format: 30.0.5 + pretty-format: 30.2.0 pure-rand: 7.0.1 slash: 3.0.0 stack-utils: 2.0.6 @@ -4813,17 +5656,17 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)): + jest-cli@30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)): dependencies: - '@jest/core': 30.1.3(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) - '@jest/test-result': 30.1.3 - '@jest/types': 30.0.5 + '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) - jest-util: 30.0.5 - jest-validate: 30.1.0 + jest-config: 30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) + jest-util: 30.2.0 + jest-validate: 30.2.0 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -4832,30 +5675,30 @@ snapshots: - supports-color - ts-node - jest-config@30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)): + jest-config@30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)): dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.1.3 - '@jest/types': 30.0.5 - babel-jest: 30.1.2(@babel/core@7.28.4) + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) chalk: 4.1.2 - ci-info: 4.3.0 + ci-info: 4.4.0 deepmerge: 4.3.1 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.1.3 - jest-docblock: 30.0.1 - jest-environment-node: 30.1.2 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.1.3 - jest-runner: 30.1.3 - jest-util: 30.0.5 - jest-validate: 30.1.0 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 micromatch: 4.0.8 parse-json: 5.2.0 - pretty-format: 30.0.5 + pretty-format: 30.2.0 slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: @@ -4865,227 +5708,227 @@ snapshots: - babel-plugin-macros - supports-color - jest-diff@30.1.2: + jest-diff@30.2.0: dependencies: '@jest/diff-sequences': 30.0.1 '@jest/get-type': 30.1.0 chalk: 4.1.2 - pretty-format: 30.0.5 + pretty-format: 30.2.0 - jest-docblock@30.0.1: + jest-docblock@30.2.0: dependencies: detect-newline: 3.1.0 - jest-each@30.1.0: + jest-each@30.2.0: dependencies: '@jest/get-type': 30.1.0 - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 chalk: 4.1.2 - jest-util: 30.0.5 - pretty-format: 30.0.5 + jest-util: 30.2.0 + pretty-format: 30.2.0 - jest-environment-node@30.1.2: + jest-environment-node@30.2.0: dependencies: - '@jest/environment': 30.1.2 - '@jest/fake-timers': 30.1.2 - '@jest/types': 30.0.5 + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.19.11 - jest-mock: 30.0.5 - jest-util: 30.0.5 - jest-validate: 30.1.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 - jest-haste-map@30.1.0: + jest-haste-map@30.2.0: dependencies: - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 '@types/node': 20.19.11 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 jest-regex-util: 30.0.1 - jest-util: 30.0.5 - jest-worker: 30.1.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 micromatch: 4.0.8 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 - jest-leak-detector@30.1.0: + jest-leak-detector@30.2.0: dependencies: '@jest/get-type': 30.1.0 - pretty-format: 30.0.5 + pretty-format: 30.2.0 - jest-matcher-utils@30.1.2: + jest-matcher-utils@30.2.0: dependencies: '@jest/get-type': 30.1.0 chalk: 4.1.2 - jest-diff: 30.1.2 - pretty-format: 30.0.5 + jest-diff: 30.2.0 + pretty-format: 30.2.0 - jest-message-util@30.1.0: + jest-message-util@30.2.0: dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 30.0.5 + '@babel/code-frame': 7.29.0 + '@jest/types': 30.2.0 '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 micromatch: 4.0.8 - pretty-format: 30.0.5 + pretty-format: 30.2.0 slash: 3.0.0 stack-utils: 2.0.6 - jest-mock@30.0.5: + jest-mock@30.2.0: dependencies: - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 '@types/node': 20.19.11 - jest-util: 30.0.5 + jest-util: 30.2.0 - jest-pnp-resolver@1.2.3(jest-resolve@30.1.3): + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): optionalDependencies: - jest-resolve: 30.1.3 + jest-resolve: 30.2.0 jest-regex-util@30.0.1: {} - jest-resolve-dependencies@30.1.3: + jest-resolve-dependencies@30.2.0: dependencies: jest-regex-util: 30.0.1 - jest-snapshot: 30.1.2 + jest-snapshot: 30.2.0 transitivePeerDependencies: - supports-color - jest-resolve@30.1.3: + jest-resolve@30.2.0: dependencies: chalk: 4.1.2 graceful-fs: 4.2.11 - jest-haste-map: 30.1.0 - jest-pnp-resolver: 1.2.3(jest-resolve@30.1.3) - jest-util: 30.0.5 - jest-validate: 30.1.0 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 slash: 3.0.0 unrs-resolver: 1.11.1 - jest-runner@30.1.3: + jest-runner@30.2.0: dependencies: - '@jest/console': 30.1.2 - '@jest/environment': 30.1.2 - '@jest/test-result': 30.1.3 - '@jest/transform': 30.1.2 - '@jest/types': 30.0.5 + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.19.11 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-docblock: 30.0.1 - jest-environment-node: 30.1.2 - jest-haste-map: 30.1.0 - jest-leak-detector: 30.1.0 - jest-message-util: 30.1.0 - jest-resolve: 30.1.3 - jest-runtime: 30.1.3 - jest-util: 30.0.5 - jest-watcher: 30.1.3 - jest-worker: 30.1.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 p-limit: 3.1.0 source-map-support: 0.5.13 transitivePeerDependencies: - supports-color - jest-runtime@30.1.3: + jest-runtime@30.2.0: dependencies: - '@jest/environment': 30.1.2 - '@jest/fake-timers': 30.1.2 - '@jest/globals': 30.1.2 + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 '@jest/source-map': 30.0.1 - '@jest/test-result': 30.1.3 - '@jest/transform': 30.1.2 - '@jest/types': 30.0.5 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.19.11 chalk: 4.1.2 - cjs-module-lexer: 2.1.0 - collect-v8-coverage: 1.0.2 - glob: 10.4.5 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 graceful-fs: 4.2.11 - jest-haste-map: 30.1.0 - jest-message-util: 30.1.0 - jest-mock: 30.0.5 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 jest-regex-util: 30.0.1 - jest-resolve: 30.1.3 - jest-snapshot: 30.1.2 - jest-util: 30.0.5 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@30.1.2: + jest-snapshot@30.2.0: dependencies: - '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4) - '@babel/types': 7.28.4 - '@jest/expect-utils': 30.1.2 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 30.2.0 '@jest/get-type': 30.1.0 - '@jest/snapshot-utils': 30.1.2 - '@jest/transform': 30.1.2 - '@jest/types': 30.0.5 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) chalk: 4.1.2 - expect: 30.1.2 + expect: 30.2.0 graceful-fs: 4.2.11 - jest-diff: 30.1.2 - jest-matcher-utils: 30.1.2 - jest-message-util: 30.1.0 - jest-util: 30.0.5 - pretty-format: 30.0.5 - semver: 7.7.2 - synckit: 0.11.11 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.4 + synckit: 0.11.12 transitivePeerDependencies: - supports-color - jest-util@30.0.5: + jest-util@30.2.0: dependencies: - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 '@types/node': 20.19.11 chalk: 4.1.2 - ci-info: 4.3.0 + ci-info: 4.4.0 graceful-fs: 4.2.11 picomatch: 4.0.3 - jest-validate@30.1.0: + jest-validate@30.2.0: dependencies: '@jest/get-type': 30.1.0 - '@jest/types': 30.0.5 + '@jest/types': 30.2.0 camelcase: 6.3.0 chalk: 4.1.2 leven: 3.1.0 - pretty-format: 30.0.5 + pretty-format: 30.2.0 - jest-watcher@30.1.3: + jest-watcher@30.2.0: dependencies: - '@jest/test-result': 30.1.3 - '@jest/types': 30.0.5 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 '@types/node': 20.19.11 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 - jest-util: 30.0.5 + jest-util: 30.2.0 string-length: 4.0.2 - jest-worker@30.1.0: + jest-worker@30.2.0: dependencies: '@types/node': 20.19.11 '@ungap/structured-clone': 1.3.0 - jest-util: 30.0.5 + jest-util: 30.2.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)): + jest@30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)): dependencies: - '@jest/core': 30.1.3(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) - '@jest/types': 30.0.5 + '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) + '@jest/types': 30.2.0 import-local: 3.2.0 - jest-cli: 30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) + jest-cli: 30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5095,12 +5938,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.1: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -5118,6 +5961,10 @@ snapshots: json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5133,6 +5980,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lint-staged@16.1.6: dependencies: chalk: 5.6.0 @@ -5157,6 +6008,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + load-esm@1.0.3: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -5167,7 +6020,9 @@ snapshots: lodash.memoize@4.1.2: {} - lodash.merge@4.6.2: {} + lodash.startcase@4.4.0: {} + + lodash@4.17.23: {} log-symbols@4.1.0: dependencies: @@ -5189,13 +6044,17 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lunr@2.3.9: {} + make-dir@4.0.0: dependencies: - semver: 7.7.1 + semver: 7.7.4 make-error@1.3.6: {} @@ -5203,8 +6062,19 @@ snapshots: dependencies: tmpl: 1.0.5 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdurl@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -5224,30 +6094,44 @@ snapshots: mimic-function@5.0.1: {} - minimatch@3.1.2: + minimatch@10.2.2: dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.2 minimist@1.2.8: {} - minipass@7.1.2: {} + minipass@7.1.3: {} + + mri@1.2.0: {} ms@2.1.3: {} + mute-stream@0.0.8: {} + mute-stream@1.0.0: {} nano-spawn@1.0.3: {} - napi-postinstall@0.3.3: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} neo-async@2.6.2: {} + nest-commander@3.20.1(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@types/inquirer@9.0.9)(@types/node@20.19.11)(typescript@5.9.2): + dependencies: + '@fig/complete-commander': 3.2.0(commander@11.1.0) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/common': 11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/inquirer': 9.0.9 + commander: 11.1.0 + cosmiconfig: 8.3.6(typescript@5.9.2) + inquirer: 8.2.7(@types/node@20.19.11) + transitivePeerDependencies: + - '@types/node' + - typescript + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -5257,7 +6141,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.20: {} + node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -5265,10 +6149,6 @@ snapshots: dependencies: path-key: 3.1.1 - once@1.4.0: - dependencies: - wrappy: 1.0.2 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -5312,21 +6192,27 @@ snapshots: os-tmpdir@1.0.2: {} - ox@0.9.6(typescript@5.9.2): + outdent@0.5.0: {} + + ox@0.9.6(typescript@5.9.2)(zod@4.3.6): dependencies: - '@adraffy/ens-normalize': 1.11.0 + '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.0(typescript@5.9.2) + abitype: 1.1.0(typescript@5.9.2)(zod@4.3.6) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: - zod + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -5343,10 +6229,16 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@2.1.0: {} + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + pako@2.1.0: {} parent-module@1.0.1: @@ -5355,21 +6247,28 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + + path-to-regexp@8.3.0: {} + + path-type@4.0.0: {} picocolors@1.1.1: {} @@ -5379,6 +6278,8 @@ snapshots: pidtree@0.6.0: {} + pify@4.0.1: {} + pirates@4.0.7: {} pkg-dir@4.2.0: @@ -5391,9 +6292,11 @@ snapshots: dependencies: fast-diff: 1.3.0 + prettier@2.8.8: {} + prettier@3.6.2: {} - pretty-format@30.0.5: + pretty-format@30.2.0: dependencies: '@jest/schemas': 30.0.5 ansi-styles: 5.2.0 @@ -5401,20 +6304,33 @@ snapshots: proxy-from-env@1.1.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} pure-rand@7.0.1: {} + quansync@0.2.11: {} + queue-microtask@1.2.3: {} react-is@18.3.1: {} + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + reflect-metadata@0.2.2: {} + regenerator-runtime@0.14.1: {} require-directory@2.1.1: {} @@ -5456,6 +6372,8 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 5.0.10 + run-async@2.4.1: {} + run-async@3.0.0: {} run-parallel@1.2.0: @@ -5474,7 +6392,7 @@ snapshots: semver@7.7.1: {} - semver@7.7.2: {} + semver@7.7.4: {} shebang-command@2.0.0: dependencies: @@ -5505,6 +6423,11 @@ snapshots: source-map@0.6.1: {} + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + sprintf-js@1.0.3: {} stack-utils@2.0.6: @@ -5536,7 +6459,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 string-width@7.2.0: dependencies: @@ -5556,6 +6479,10 @@ snapshots: dependencies: ansi-regex: 6.2.0 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -5564,6 +6491,10 @@ snapshots: strip-json-comments@3.1.1: {} + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + superstruct@0.15.5: {} superstruct@2.0.2: {} @@ -5580,14 +6511,27 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - test-exclude@6.0.0: + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + term-size@2.2.1: {} + + test-exclude@8.0.0: dependencies: '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 + glob: 13.0.6 + minimatch: 10.2.2 text-encoding-utf-8@1.0.2: {} + through@2.3.8: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -5598,49 +6542,55 @@ snapshots: dependencies: is-number: 7.0.0 + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + toml@3.0.0: {} tr46@0.0.3: {} - tronweb@6.0.4(bufferutil@4.0.9)(utf-8-validate@5.0.10): + tronweb@6.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): dependencies: '@babel/runtime': 7.26.10 - axios: 1.11.0 + axios: 1.13.5 bignumber.js: 9.1.2 ethereum-cryptography: 2.2.1 ethers: 6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) eventemitter3: 5.0.1 google-protobuf: 3.21.4 semver: 7.7.1 - validator: 13.12.0 + validator: 13.15.23 transitivePeerDependencies: - bufferutil - debug - utf-8-validate - ts-api-utils@2.1.0(typescript@5.9.2): + ts-api-utils@2.4.0(typescript@5.9.2): dependencies: typescript: 5.9.2 - ts-jest@29.4.1(@babel/core@7.28.4)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.4))(jest-util@30.0.5)(jest@30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)))(typescript@5.9.2): + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)))(typescript@5.9.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 30.1.3(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) + jest: 30.2.0(@types/node@20.19.11)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.7.2 + semver: 7.7.4 type-fest: 4.41.0 typescript: 5.9.2 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.28.4 - '@jest/transform': 30.1.2 - '@jest/types': 30.0.5 - babel-jest: 30.1.2(@babel/core@7.28.4) - jest-util: 30.0.5 + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) + jest-util: 30.2.0 ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2): dependencies: @@ -5687,18 +6637,37 @@ snapshots: type-fest@4.41.0: {} + typedoc@0.28.17(typescript@5.9.2): + dependencies: + '@gerrit0/mini-shiki': 3.22.0 + lunr: 2.3.9 + markdown-it: 14.1.1 + minimatch: 10.2.2 + typescript: 5.9.2 + yaml: 2.8.1 + typescript@5.9.2: {} + uc.micro@2.1.0: {} + uglify-js@3.19.3: optional: true + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + undici-types@6.19.8: {} undici-types@6.21.0: {} + universalify@0.1.2: {} + unrs-resolver@1.11.1: dependencies: - napi-postinstall: 0.3.3 + napi-postinstall: 0.3.4 optionalDependencies: '@unrs/resolver-binding-android-arm-eabi': 1.11.1 '@unrs/resolver-binding-android-arm64': 1.11.1 @@ -5720,9 +6689,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.1.3(browserslist@4.25.4): + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: - browserslist: 4.25.4 + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 @@ -5743,21 +6712,21 @@ snapshots: v8-to-istanbul@9.3.0: dependencies: - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - validator@13.12.0: {} + validator@13.15.23: {} - viem@2.40.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10): + viem@2.40.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@4.3.6): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.0(typescript@5.9.2) + abitype: 1.1.0(typescript@5.9.2)(zod@4.3.6) isows: 1.0.7(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.9.6(typescript@5.9.2) + ox: 0.9.6(typescript@5.9.2)(zod@4.3.6) ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.2 @@ -5803,9 +6772,9 @@ snapshots: wrap-ansi@8.1.0: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.1.2 wrap-ansi@9.0.2: dependencies: @@ -5813,8 +6782,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 - wrappy@1.0.2: {} - write-file-atomic@5.0.1: dependencies: imurmurhash: 0.1.4 @@ -5858,3 +6825,5 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} + + zod@4.3.6: {} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..e637196 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { BlockchainModule } from './blockchain/blockchain.module'; +import { CliModule } from './cli/cli.module'; +import { ConfigModule } from './config/config.module'; +import { IntentModule } from './intent/intent.module'; +import { QuoteModule } from './quote/quote.module'; +import { StatusModule } from './status/status.module'; + +@Module({ + imports: [ConfigModule, BlockchainModule, IntentModule, QuoteModule, StatusModule, CliModule], +}) +export class AppModule {} diff --git a/src/blockchain/address-normalizer.service.ts b/src/blockchain/address-normalizer.service.ts new file mode 100644 index 0000000..2f80283 --- /dev/null +++ b/src/blockchain/address-normalizer.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import { + BlockchainAddress, + ChainType, + EvmAddress, + SvmAddress, + TronAddress, + UniversalAddress, +} from '@/shared/types'; + +import { ChainRegistryService } from './chain-registry.service'; + +@Injectable() +export class AddressNormalizerService { + constructor(private readonly registry: ChainRegistryService) {} + + normalize(address: BlockchainAddress, chainType: ChainType): UniversalAddress { + return this.registry.get(chainType).normalize(address as string); + } + + denormalize(address: UniversalAddress, chainType: ChainType): BlockchainAddress { + return this.registry.get(chainType).denormalize(address); + } + + denormalizeToEvm(address: UniversalAddress): EvmAddress { + return this.registry.get(ChainType.EVM).denormalize(address) as EvmAddress; + } + + denormalizeToTvm(address: UniversalAddress): TronAddress { + return this.registry.get(ChainType.TVM).denormalize(address) as TronAddress; + } + + denormalizeToSvm(address: UniversalAddress): SvmAddress { + return this.registry.get(ChainType.SVM).denormalize(address) as SvmAddress; + } +} diff --git a/src/blockchain/base-publisher.ts b/src/blockchain/base-publisher.ts deleted file mode 100644 index 7ae0899..0000000 --- a/src/blockchain/base-publisher.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Base Publisher Abstract Class - * - * Defines the common interface and structure for all blockchain publishers - * in the Routes CLI system. Publishers are responsible for taking cross-chain - * intents and publishing them to specific blockchain networks. - * - * Each concrete implementation (EVMPublisher, TVMPublisher, SVMPublisher) must - * handle the blockchain-specific details while maintaining this common interface. - * - * @example - * ```typescript - * class CustomPublisher extends BasePublisher { - * async publish(intent: Intent, privateKey: string): Promise { - * // Implementation specific to your blockchain - * return { success: true, transactionHash: '0x...' }; - * } - * - * // ... implement other abstract methods - * } - * ``` - */ - -import { UniversalAddress } from '@/core/types/universal-address'; - -import { Intent } from '../core/interfaces/intent'; - -/** - * Result object returned by publisher operations. - * - * Contains the outcome of intent publishing with optional transaction details - * and error information for debugging and user feedback. - */ -export interface PublishResult { - /** Whether the publish operation was successful */ - success: boolean; - /** Transaction hash on the blockchain (if successful) */ - transactionHash?: string; - /** Computed intent hash for tracking purposes */ - intentHash?: string; - /** Error message if the operation failed */ - error?: string; - /** Vault or contract address created (if applicable) */ - vaultAddress?: string; - /** Decoded program data (SVM only) */ - decodedData?: any; -} - -/** - * Abstract base class for blockchain publishers. - * - * Provides common structure and interface for publishing cross-chain intents - * to different blockchain networks. Each implementation handles the specific - * blockchain protocol details while maintaining consistent behavior. - */ -export abstract class BasePublisher { - protected rpcUrl: string; - - /** - * Creates a new publisher instance. - * - * @param rpcUrl - RPC endpoint URL for blockchain communication - */ - constructor(rpcUrl: string) { - this.rpcUrl = rpcUrl; - } - - /** - * Publishes an intent to the blockchain. - * - * Takes a cross-chain intent with UniversalAddresses and publishes it to the - * specific blockchain network. Implementations must handle address denormalization, - * transaction signing, and error handling appropriate for their blockchain type. - * - * @param privateKey - Private key for transaction signing (format depends on blockchain) - * @returns Promise resolving to PublishResult with transaction details or error info - * @throws {Error} When publishing fails due to network, validation, or other issues - * - * @example - * ```typescript - * const result = await publisher.publish(intent, privateKey); - * if (result.success) { - * console.log(`Published: ${result.transactionHash}`); - * } else { - * console.error(`Failed: ${result.error}`); - * } - * ``` - */ - abstract publish( - source: bigint, - destination: bigint, - reward: Intent['reward'], - encodedRoute: string, - privateKey: string, - portalAddress?: UniversalAddress, - proverAddress?: UniversalAddress - ): Promise; - - /** - * Gets the native token balance of an address. - * - * Retrieves the balance of the blockchain's native token (ETH, TRX, SOL) for - * a given address. Used for validation and user information display. - * - * @param address - Address to check balance for (in chain-native format) - * @param chainId - Optional chain ID for chain-specific balance checks - * @returns Promise resolving to balance in smallest unit (wei, sun, lamports) - * @throws {Error} When balance query fails - * - * @example - * ```typescript - * const balance = await publisher.getBalance('0x742d35Cc...'); - * console.log(`Balance: ${balance} wei`); - * ``` - */ - abstract getBalance(address: string, chainId?: bigint): Promise; -} diff --git a/src/blockchain/base.publisher.ts b/src/blockchain/base.publisher.ts new file mode 100644 index 0000000..95ec825 --- /dev/null +++ b/src/blockchain/base.publisher.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; + +import { RoutesCliError } from '@/shared/errors'; +import { KeyHandle } from '@/shared/security'; +import { ChainConfig, Intent, UniversalAddress } from '@/shared/types'; +import { logger } from '@/utils/logger'; + +import { ChainRegistryService } from './chain-registry.service'; + +export interface IntentStatus { + fulfilled: boolean; + solver?: string; + fulfillmentTxHash?: string; + blockNumber?: bigint; + timestamp?: number; +} + +export interface PublishResult { + success: boolean; + transactionHash?: string; + intentHash?: string; + error?: string; + vaultAddress?: string; + decodedData?: unknown; +} + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +@Injectable() +export abstract class BasePublisher { + constructor( + protected readonly rpcUrl: string, + protected readonly registry: ChainRegistryService + ) {} + + abstract publish( + source: bigint, + destination: bigint, + reward: Intent['reward'], + encodedRoute: string, + keyHandle: KeyHandle, + portalAddress?: UniversalAddress, + proverAddress?: UniversalAddress + ): Promise; + + abstract getBalance(address: string, chainId?: bigint): Promise; + + abstract validate( + reward: Intent['reward'], + senderAddress: string, + chainId: bigint + ): Promise; + + abstract getStatus( + intentHash: string, + chain: ChainConfig, + portalAddress?: UniversalAddress + ): Promise; + + protected handleError(error: unknown): PublishResult { + const message = error instanceof Error ? error.message : String(error); + logger.stopSpinner(); + return { success: false, error: message }; + } + + protected async runSafely(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error: unknown) { + return this.handleError(error); + } + } + + protected runPreflightChecks(sourceChainId: bigint): void { + if (!this.registry.isRegistered(sourceChainId)) { + throw RoutesCliError.unsupportedChain(sourceChainId); + } + } +} diff --git a/src/blockchain/blockchain.module.ts b/src/blockchain/blockchain.module.ts new file mode 100644 index 0000000..090bf2e --- /dev/null +++ b/src/blockchain/blockchain.module.ts @@ -0,0 +1,35 @@ +import { Global, Module } from '@nestjs/common'; + +import { ConfigModule } from '@/config/config.module'; + +import { IntentConverterService } from './encoding/intent-converter.service'; +import { PortalEncoderService } from './encoding/portal-encoder.service'; +import { AddressNormalizerService } from './address-normalizer.service'; +import { ChainRegistryService } from './chain-registry.service'; +import { ChainsService } from './chains.service'; +import { PublisherFactory } from './publisher-factory.service'; +import { RpcService } from './rpc.service'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [ + ChainRegistryService, + AddressNormalizerService, + ChainsService, + RpcService, + PublisherFactory, + PortalEncoderService, + IntentConverterService, + ], + exports: [ + ChainRegistryService, + AddressNormalizerService, + ChainsService, + RpcService, + PublisherFactory, + PortalEncoderService, + IntentConverterService, + ], +}) +export class BlockchainModule {} diff --git a/src/blockchain/chain-handler.interface.ts b/src/blockchain/chain-handler.interface.ts new file mode 100644 index 0000000..fbfbd19 --- /dev/null +++ b/src/blockchain/chain-handler.interface.ts @@ -0,0 +1,38 @@ +import { ChainType } from '@/shared/types'; +import { BlockchainAddress } from '@/shared/types'; +import { UniversalAddress } from '@/shared/types'; + +/** + * Chain handler interface for pluggable chain-type support. + * + * Implementing this interface and registering via `ChainRegistryService.bootstrap()` is all + * that is needed to add support for a new blockchain type — no switch statements to update. + */ +export interface ChainHandler { + /** The blockchain type this handler is responsible for. */ + readonly chainType: ChainType; + + /** + * Returns true if the given address string is valid for this chain type. + * Used to gate user input before normalization. + */ + validateAddress(address: string): boolean; + + /** + * Converts a chain-native address string to UniversalAddress format. + * @throws {RoutesCliError} When the address is invalid for this chain type. + */ + normalize(address: string): UniversalAddress; + + /** + * Converts a UniversalAddress back to the chain-native address format. + * @throws {Error} When denormalization fails. + */ + denormalize(address: UniversalAddress): BlockchainAddress; + + /** + * Returns a human-readable description of the expected address format. + * Used in error messages and CLI prompts. + */ + getAddressFormat(): string; +} diff --git a/src/blockchain/chain-registry.service.ts b/src/blockchain/chain-registry.service.ts new file mode 100644 index 0000000..08d1391 --- /dev/null +++ b/src/blockchain/chain-registry.service.ts @@ -0,0 +1,43 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; + +import { RoutesCliError } from '@/shared/errors'; +import { ChainType } from '@/shared/types'; + +import { EvmChainHandler } from './evm/evm-chain-handler'; +import { SvmChainHandler } from './svm/svm-chain-handler'; +import { TvmChainHandler } from './tvm/tvm-chain-handler'; +import { ChainHandler } from './chain-handler.interface'; + +@Injectable() +export class ChainRegistryService implements OnModuleInit { + private readonly handlers = new Map(); + private readonly registeredChainIds = new Set(); + + onModuleInit(): void { + this.bootstrap([new EvmChainHandler(), new TvmChainHandler(), new SvmChainHandler()]); + } + + bootstrap(handlers: ChainHandler[]): void { + for (const handler of handlers) { + this.handlers.set(handler.chainType, handler); + } + } + + get(chainType: ChainType): ChainHandler { + const handler = this.handlers.get(chainType); + if (!handler) throw RoutesCliError.unsupportedChain(chainType); + return handler; + } + + getAll(): ChainHandler[] { + return [...this.handlers.values()]; + } + + registerChainId(chainId: bigint): void { + this.registeredChainIds.add(chainId); + } + + isRegistered(chainId: bigint): boolean { + return this.registeredChainIds.has(chainId); + } +} diff --git a/src/blockchain/chains.config.ts b/src/blockchain/chains.config.ts new file mode 100644 index 0000000..0c0b02b --- /dev/null +++ b/src/blockchain/chains.config.ts @@ -0,0 +1,164 @@ +import { arbitrum, bsc, hyperEvm, mainnet, polygon, ronin, sonic } from 'viem/chains'; + +import { ChainType } from '@/shared/types'; + +export interface RawChainConfig { + id: bigint; + name: string; + env: 'production' | 'development'; + type: ChainType; + rpcUrl: string; + portalAddress?: string; // raw string, normalized lazily by ChainsService + proverAddress?: string; + nativeCurrency: { name: string; symbol: string; decimals: number }; +} + +export const RAW_CHAIN_CONFIGS: RawChainConfig[] = [ + // EVM - Production + { + id: BigInt(mainnet.id), + name: 'Ethereum', + type: ChainType.EVM, + env: 'production', + rpcUrl: mainnet.rpcUrls.default.http[0], + nativeCurrency: mainnet.nativeCurrency, + }, + { + id: 10n, + name: 'Optimism', + type: ChainType.EVM, + env: 'production', + rpcUrl: 'https://mainnet.optimism.io', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + { + id: BigInt(bsc.id), + name: bsc.name, + type: ChainType.EVM, + env: 'production', + rpcUrl: bsc.rpcUrls.default.http[0], + nativeCurrency: bsc.nativeCurrency, + }, + { + id: 8453n, + name: 'Base', + type: ChainType.EVM, + env: 'production', + rpcUrl: 'https://mainnet.base.org', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + { + id: BigInt(arbitrum.id), + name: arbitrum.name, + type: ChainType.EVM, + env: 'production', + rpcUrl: arbitrum.rpcUrls.default.http[0], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + { + id: BigInt(polygon.id), + name: polygon.name, + type: ChainType.EVM, + env: 'production', + rpcUrl: polygon.rpcUrls.default.http[0], + nativeCurrency: polygon.nativeCurrency, + }, + { + id: BigInt(ronin.id), + name: ronin.name, + type: ChainType.EVM, + env: 'production', + rpcUrl: ronin.rpcUrls.default.http[0], + nativeCurrency: ronin.nativeCurrency, + }, + { + id: BigInt(sonic.id), + name: sonic.name, + type: ChainType.EVM, + env: 'production', + rpcUrl: sonic.rpcUrls.default.http[0], + nativeCurrency: sonic.nativeCurrency, + }, + { + id: BigInt(hyperEvm.id), + name: hyperEvm.name, + type: ChainType.EVM, + env: 'production', + rpcUrl: hyperEvm.rpcUrls.default.http[0], + nativeCurrency: hyperEvm.nativeCurrency, + }, + + // EVM - Development + { + id: 84532n, + name: 'Base Sepolia', + type: ChainType.EVM, + env: 'development', + rpcUrl: 'https://sepolia.base.org', + proverAddress: '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + { + id: 11155420n, + name: 'Optimism Sepolia', + type: ChainType.EVM, + env: 'development', + rpcUrl: 'https://sepolia.optimism.io', + proverAddress: '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + { + id: 9746n, + name: 'Plasma Testnet', + type: ChainType.EVM, + env: 'development', + rpcUrl: 'https://rpc.testnet.plasm.technology', + proverAddress: '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + { + id: 11155111n, + name: 'Sepolia', + type: ChainType.EVM, + env: 'development', + rpcUrl: 'https://rpc.sepolia.org', + proverAddress: '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + + // TVM + { + id: 728126428n, + name: 'Tron', + type: ChainType.TVM, + env: 'production', + rpcUrl: 'https://api.trongrid.io', + nativeCurrency: { name: 'Tron', symbol: 'TRX', decimals: 6 }, + }, + { + id: 2494104990n, + name: 'Tron Shasta', + type: ChainType.TVM, + env: 'development', + rpcUrl: 'https://api.shasta.trongrid.io', + nativeCurrency: { name: 'Tron', symbol: 'TRX', decimals: 6 }, + }, + + // SVM + { + id: 1399811149n, + name: 'Solana', + type: ChainType.SVM, + env: 'production', + rpcUrl: 'https://api.mainnet-beta.solana.com', + nativeCurrency: { name: 'Solana', symbol: 'SOL', decimals: 9 }, + }, + { + id: 1399811150n, + name: 'Solana Devnet', + type: ChainType.SVM, + env: 'development', + rpcUrl: 'https://api.devnet.solana.com', + nativeCurrency: { name: 'Solana', symbol: 'SOL', decimals: 9 }, + }, +]; diff --git a/src/blockchain/chains.service.ts b/src/blockchain/chains.service.ts new file mode 100644 index 0000000..c7086f1 --- /dev/null +++ b/src/blockchain/chains.service.ts @@ -0,0 +1,70 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; + +import { ConfigService } from '@/config/config.service'; +import { RoutesCliError } from '@/shared/errors'; +import { ChainConfig } from '@/shared/types'; + +import { AddressNormalizerService } from './address-normalizer.service'; +import { ChainRegistryService } from './chain-registry.service'; +import { RAW_CHAIN_CONFIGS, RawChainConfig } from './chains.config'; + +@Injectable() +export class ChainsService implements OnModuleInit { + private chains: ChainConfig[] = []; + + constructor( + private readonly config: ConfigService, + private readonly normalizer: AddressNormalizerService, + private readonly registry: ChainRegistryService + ) {} + + onModuleInit(): void { + const env = this.config.getChainsEnv(); + this.chains = RAW_CHAIN_CONFIGS.filter(c => c.env === env || c.env === 'production').map(c => + this.normalizeChain(c) + ); + + for (const chain of this.chains) { + this.registry.registerChainId(chain.id); + } + } + + private normalizeChain(raw: RawChainConfig): ChainConfig { + return { + ...raw, + portalAddress: raw.portalAddress + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.normalizer.normalize(raw.portalAddress as any, raw.type) + : undefined, + proverAddress: raw.proverAddress + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.normalizer.normalize(raw.proverAddress as any, raw.type) + : undefined, + }; + } + + listChains(): ChainConfig[] { + return this.chains; + } + + getChainById(id: bigint): ChainConfig { + const chain = this.chains.find(c => c.id === id); + if (!chain) throw RoutesCliError.unsupportedChain(id); + return chain; + } + + findChainById(id: bigint): ChainConfig | undefined { + return this.chains.find(c => c.id === id); + } + + getChainByName(name: string): ChainConfig { + const chain = this.chains.find(c => c.name.toLowerCase() === name.toLowerCase()); + if (!chain) throw RoutesCliError.unsupportedChain(name); + return chain; + } + + resolveChain(nameOrId: string): ChainConfig { + if (/^\d+$/.test(nameOrId)) return this.getChainById(BigInt(nameOrId)); + return this.getChainByName(nameOrId); + } +} diff --git a/src/blockchain/encoding/intent-converter.service.ts b/src/blockchain/encoding/intent-converter.service.ts new file mode 100644 index 0000000..6d6ce65 --- /dev/null +++ b/src/blockchain/encoding/intent-converter.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; + +import { Hex } from 'viem'; + +import { EvmAddress, Intent } from '@/shared/types'; + +import { AddressNormalizerService } from '../address-normalizer.service'; + +@Injectable() +export class IntentConverterService { + constructor(private readonly addrNorm: AddressNormalizerService) {} + + toEVMIntent(intent: Intent): { + intentHash: Hex | undefined; + destination: bigint; + sourceChainId: bigint; + route: ReturnType; + reward: ReturnType; + } { + return { + intentHash: intent.intentHash, + destination: intent.destination, + sourceChainId: intent.sourceChainId, + route: this.toRouteEVMIntent(intent.route), + reward: this.toRewardEVMIntent(intent.reward), + }; + } + + toRewardEVMIntent(reward: Intent['reward']): { + deadline: bigint; + creator: EvmAddress; + prover: EvmAddress; + nativeAmount: bigint; + tokens: { amount: bigint; token: EvmAddress }[]; + } { + return { + deadline: reward.deadline, + creator: this.addrNorm.denormalizeToEvm(reward.creator), + prover: this.addrNorm.denormalizeToEvm(reward.prover), + nativeAmount: reward.nativeAmount, + tokens: reward.tokens.map(token => ({ + amount: token.amount, + token: this.addrNorm.denormalizeToEvm(token.token), + })), + }; + } + + toRouteEVMIntent(route: Intent['route']): { + salt: Hex; + deadline: bigint; + portal: EvmAddress; + nativeAmount: bigint; + tokens: { amount: bigint; token: EvmAddress }[]; + calls: { data: Hex; target: EvmAddress; value: bigint }[]; + } { + return { + salt: route.salt, + deadline: route.deadline, + portal: this.addrNorm.denormalizeToEvm(route.portal), + nativeAmount: route.nativeAmount, + tokens: route.tokens.map(token => ({ + amount: token.amount, + token: this.addrNorm.denormalizeToEvm(token.token), + })), + calls: route.calls.map(call => ({ + data: call.data, + target: this.addrNorm.denormalizeToEvm(call.target), + value: call.value, + })), + }; + } +} diff --git a/src/blockchain/encoding/portal-encoder.service.ts b/src/blockchain/encoding/portal-encoder.service.ts new file mode 100644 index 0000000..b2aa115 --- /dev/null +++ b/src/blockchain/encoding/portal-encoder.service.ts @@ -0,0 +1,237 @@ +import { Injectable } from '@nestjs/common'; + +import { decodeAbiParameters, encodeAbiParameters, Hex } from 'viem'; + +import { EVMRewardAbiItem, EVMRouteAbiItem } from '@/commons/abis/portal.abi'; +import { RewardInstruction, RouteInstruction } from '@/commons/types/portal-idl-coder.type'; +import { bufferToBytes, bytes32ToAddress } from '@/commons/utils/converter'; +import { toSvmRewardForCoder, toSvmRouteForCoder } from '@/commons/utils/instruments'; +import { portalBorshCoder } from '@/commons/utils/portal-borsh-coder'; +import { TvmUtils } from '@/commons/utils/tvm-utils'; +import { ChainType, Intent, SvmAddress } from '@/shared/types'; + +import { AddressNormalizerService } from '../address-normalizer.service'; + +@Injectable() +export class PortalEncoderService { + constructor(private readonly addrNorm: AddressNormalizerService) {} + + encode(data: Intent['route'] | Intent['reward'], chainType: ChainType): Hex { + switch (chainType) { + case ChainType.EVM: + case ChainType.TVM: + return this.encodeEvm(data); + case ChainType.SVM: + return this.encodeSvm(data); + default: + throw new Error(`Unsupported chain type: ${chainType}`); + } + } + + decode( + data: Buffer | string, + chainType: ChainType, + dataType: Type + ): Type extends 'route' ? Intent['route'] : Intent['reward'] { + switch (chainType) { + case ChainType.EVM: + return this.decodeEvm(data, dataType); + case ChainType.TVM: + return this.decodeTvm(data, dataType); + case ChainType.SVM: + return this.decodeSvm(data, dataType); + default: + throw new Error(`Unsupported chain type: ${chainType}`); + } + } + + isRoute(data: Intent['route'] | Intent['reward']): data is Intent['route'] { + return 'salt' in data && 'portal' in data && 'calls' in data; + } + + private encodeEvm(data: Intent['route'] | Intent['reward']): Hex { + if (this.isRoute(data)) { + return encodeAbiParameters( + [EVMRouteAbiItem], + [ + { + salt: data.salt, + deadline: data.deadline, + nativeAmount: data.nativeAmount, + portal: this.addrNorm.denormalizeToEvm(data.portal), + tokens: data.tokens.map(t => ({ + token: this.addrNorm.denormalizeToEvm(t.token), + amount: t.amount, + })), + calls: data.calls.map(c => ({ + target: this.addrNorm.denormalizeToEvm(c.target), + data: c.data, + value: c.value, + })), + }, + ] + ); + } else { + return encodeAbiParameters( + [EVMRewardAbiItem], + [ + { + deadline: data.deadline, + creator: this.addrNorm.denormalizeToEvm(data.creator), + prover: this.addrNorm.denormalizeToEvm(data.prover), + nativeAmount: data.nativeAmount, + tokens: data.tokens.map(t => ({ + token: this.addrNorm.denormalizeToEvm(t.token), + amount: t.amount, + })), + }, + ] + ); + } + } + + private encodeSvm(data: Intent['route'] | Intent['reward']): Hex { + if (this.isRoute(data)) { + return bufferToBytes( + portalBorshCoder.types.encode('Route', toSvmRouteForCoder(data)) + ); + } else { + return bufferToBytes( + portalBorshCoder.types.encode('Reward', toSvmRewardForCoder(data)) + ); + } + } + + private decodeEvm( + data: Buffer | string, + dataType: Type + ): Type extends 'route' ? Intent['route'] : Intent['reward'] { + const dataString = typeof data === 'string' ? data : '0x' + data.toString('hex'); + + if (dataType === 'reward') { + const decoded = decodeAbiParameters([EVMRewardAbiItem], dataString as Hex)[0]; + + return { + deadline: decoded.deadline, + creator: this.addrNorm.normalize(decoded.creator, ChainType.EVM), + prover: this.addrNorm.normalize(decoded.prover, ChainType.EVM), + nativeAmount: decoded.nativeAmount, + tokens: decoded.tokens.map(t => ({ + token: this.addrNorm.normalize(t.token, ChainType.EVM), + amount: t.amount, + })), + } as Intent['reward'] as Type extends 'route' ? Intent['route'] : Intent['reward']; + } + + const decoded = decodeAbiParameters([EVMRouteAbiItem], dataString as Hex)[0]; + return { + salt: decoded.salt, + deadline: decoded.deadline, + portal: this.addrNorm.normalize(decoded.portal, ChainType.EVM), + nativeAmount: decoded.nativeAmount || 0n, + tokens: decoded.tokens.map(t => ({ + token: this.addrNorm.normalize(t.token, ChainType.EVM), + amount: t.amount, + })), + calls: decoded.calls.map(c => ({ + target: this.addrNorm.normalize(c.target, ChainType.EVM), + data: c.data, + value: c.value, + })), + } as Intent['route'] as Type extends 'route' ? Intent['route'] : Intent['reward']; + } + + private decodeTvm( + data: Buffer | string, + dataType: Type + ): Type extends 'route' ? Intent['route'] : Intent['reward'] { + const dataString = typeof data === 'string' ? data : '0x' + data.toString('hex'); + + if (dataType === 'reward') { + const decoded = decodeAbiParameters([EVMRewardAbiItem], dataString as Hex)[0]; + + return { + deadline: decoded.deadline, + creator: this.addrNorm.normalize(TvmUtils.fromEvm(decoded.creator), ChainType.TVM), + prover: this.addrNorm.normalize(TvmUtils.fromEvm(decoded.prover), ChainType.TVM), + nativeAmount: decoded.nativeAmount, + tokens: decoded.tokens.map(t => ({ + token: this.addrNorm.normalize(TvmUtils.fromEvm(t.token), ChainType.TVM), + amount: t.amount, + })), + } as Intent['reward'] as Type extends 'route' ? Intent['route'] : Intent['reward']; + } + + const decoded = decodeAbiParameters([EVMRouteAbiItem], dataString as Hex)[0]; + return { + salt: decoded.salt, + deadline: decoded.deadline, + portal: this.addrNorm.normalize(TvmUtils.fromEvm(decoded.portal), ChainType.TVM), + nativeAmount: decoded.nativeAmount || 0n, + tokens: decoded.tokens.map(t => ({ + token: this.addrNorm.normalize(TvmUtils.fromEvm(t.token), ChainType.TVM), + amount: t.amount, + })), + calls: decoded.calls.map(c => ({ + target: this.addrNorm.normalize(TvmUtils.fromEvm(c.target), ChainType.TVM), + data: c.data, + value: c.value, + })), + } as Intent['route'] as Type extends 'route' ? Intent['route'] : Intent['reward']; + } + + private decodeSvm( + data: Buffer | string, + dataType: Type + ): Type extends 'route' ? Intent['route'] : Intent['reward'] { + const buffer = + typeof data === 'string' + ? Buffer.from(data.startsWith('0x') ? data.substring(2) : data, 'hex') + : data; + + if (dataType === 'route') { + const decoded = portalBorshCoder.types.decode('Route', buffer); + + if (decoded === null) { + throw new Error('Unable to decode SVM route'); + } + + const route: Intent['route'] = { + salt: bufferToBytes(decoded.salt[0]), + deadline: BigInt(decoded.deadline.toString()), + portal: this.addrNorm.normalize(bytes32ToAddress(decoded.portal[0]), ChainType.SVM), + nativeAmount: BigInt(decoded.native_amount.toString()), + tokens: decoded.tokens.map(t => ({ + token: this.addrNorm.normalize(t.token.toBase58() as SvmAddress, ChainType.SVM), + amount: BigInt(t.amount.toString()), + })), + calls: decoded.calls.map(c => ({ + target: this.addrNorm.normalize(bytes32ToAddress(c.target[0]), ChainType.SVM), + data: bufferToBytes(c.data), + value: 0n, + })), + }; + + return route as Type extends 'route' ? Intent['route'] : Intent['reward']; + } + + const decoded = portalBorshCoder.types.decode('Reward', buffer); + + if (decoded === null) { + throw new Error('Unable to decode SVM reward'); + } + + const reward: Intent['reward'] = { + deadline: BigInt(decoded.deadline.toString()), + creator: this.addrNorm.normalize(decoded.creator.toBase58() as SvmAddress, ChainType.SVM), + prover: this.addrNorm.normalize(decoded.prover.toBase58() as SvmAddress, ChainType.SVM), + nativeAmount: BigInt(decoded.native_amount.toString()), + tokens: decoded.tokens.map(t => ({ + token: this.addrNorm.normalize(t.token.toBase58() as SvmAddress, ChainType.SVM), + amount: BigInt(t.amount.toString()), + })), + }; + + return reward as Type extends 'route' ? Intent['route'] : Intent['reward']; + } +} diff --git a/src/blockchain/evm-publisher.ts b/src/blockchain/evm-publisher.ts deleted file mode 100644 index bca0444..0000000 --- a/src/blockchain/evm-publisher.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * EVM Chain Publisher - */ - -import { - Address, - Chain, - createPublicClient, - createWalletClient, - encodeFunctionData, - erc20Abi, - Hex, - http, - maxUint256, - parseEventLogs, -} from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; -import * as chains from 'viem/chains'; - -import { portalAbi } from '@/commons/abis/portal.abi'; -import { getChainById } from '@/config/chains'; -import { Intent } from '@/core/interfaces/intent'; -import { UniversalAddress } from '@/core/types/universal-address'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; -import { logger } from '@/utils/logger'; - -import { BasePublisher, PublishResult } from './base-publisher'; - -export class EvmPublisher extends BasePublisher { - async publish( - source: bigint, - destination: bigint, - reward: Intent['reward'], - encodedRoute: string, - privateKey: string, - portalAddress?: UniversalAddress, - proverAddress?: UniversalAddress - ): Promise { - try { - const account = privateKeyToAccount(privateKey as Hex); - const chain = this.getChain(source); - - const walletClient = createWalletClient({ - account, - chain, - transport: http(this.rpcUrl), - }); - - const publicClient = createPublicClient({ - chain, - transport: http(this.rpcUrl), - }); - - // Get Portal address - const sourceChainConfig = getChainById(source); - const destinationChainConfig = getChainById(destination); - - const portalAddrUniversal = portalAddress ?? sourceChainConfig?.portalAddress; - - if (!portalAddrUniversal) { - throw new Error(`No Portal address configured for chain ${source}`); - } - - const finalPortalAddress = AddressNormalizer.denormalizeToEvm(portalAddrUniversal); - - if (!destinationChainConfig) { - throw new Error(`Destination chain is not configured ${destination}`); - } - - // Check native balance if required - if (reward.nativeAmount > 0n) { - logger.spinner('Checking native balance...'); - const balance = await publicClient.getBalance({ - address: account.address, - }); - - if (balance < reward.nativeAmount) { - logger.fail( - `Insufficient native balance. Required: ${reward.nativeAmount}, Available: ${balance}` - ); - throw new Error( - `Insufficient native balance. Required: ${reward.nativeAmount}, Available: ${balance}` - ); - } - logger.succeed(`Native balance sufficient: ${balance} wei`); - } - - // Check and approve tokens for the reward - if (reward.tokens.length > 0) { - logger.info('Checking token balances and approvals...'); - } - - for (let i = 0; i < reward.tokens.length; i++) { - const token = reward.tokens[i]; - const tokenAddress = AddressNormalizer.denormalizeToEvm(token.token); - - // Check token balance first - logger.spinner( - `Checking balance for token ${i + 1}/${reward.tokens.length}: ${tokenAddress}` - ); - - const tokenBalance = await publicClient.readContract({ - address: tokenAddress, - abi: erc20Abi, - functionName: 'balanceOf', - args: [account.address], - }); - - if (tokenBalance < token.amount) { - logger.fail(`Insufficient token balance for ${tokenAddress}`); - throw new Error( - `Insufficient token balance for ${tokenAddress}. Required: ${token.amount}, Available: ${tokenBalance}` - ); - } - - logger.succeed(`Token balance sufficient: ${tokenBalance}`); - - // Check current allowance - const allowance = await publicClient.readContract({ - address: tokenAddress, - abi: erc20Abi, - functionName: 'allowance', - args: [account.address, finalPortalAddress], - }); - - if (allowance < token.amount) { - logger.spinner(`Approving token ${tokenAddress}...`); - - // Approve max amount to avoid future approvals - const approveTx = await walletClient.writeContract({ - address: tokenAddress, - abi: erc20Abi, - functionName: 'approve', - args: [finalPortalAddress, maxUint256], - }); - - // Wait for approval confirmation - logger.updateSpinner('Waiting for approval confirmation...'); - const approvalReceipt = await publicClient.waitForTransactionReceipt({ - hash: approveTx, - confirmations: 2, - }); - - if (approvalReceipt.status !== 'success') { - logger.fail(`Token approval failed for ${tokenAddress}`); - throw new Error(`Token approval failed for ${tokenAddress}`); - } - - logger.succeed(`Token approved: ${tokenAddress}`); - } else { - logger.info(`Token already approved: ${tokenAddress}`); - } - } - - // Prepare reward struct - const evmReward = { - deadline: reward.deadline, - nativeAmount: reward.nativeAmount, - creator: AddressNormalizer.denormalizeToEvm(reward.creator), - prover: AddressNormalizer.denormalizeToEvm(proverAddress ?? reward.prover), - tokens: reward.tokens.map(t => ({ - token: AddressNormalizer.denormalizeToEvm(t.token), - amount: t.amount, - })), - }; - - // Encode the function call - const data = encodeFunctionData({ - abi: portalAbi, - functionName: 'publishAndFund', - args: [destination, encodedRoute as Hex, evmReward, false], - }); - - // Send transaction with native value if required - logger.spinner('Publishing intent to Portal contract...'); - const hash = await walletClient.sendTransaction({ - to: finalPortalAddress, - data, - value: reward.nativeAmount, - }); - - // Wait for transaction receipt - logger.updateSpinner('Waiting for transaction confirmation...'); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); - logger.succeed('Transaction confirmed'); - - if (receipt.status === 'success') { - const [intentPublishEvent] = parseEventLogs({ - abi: portalAbi, - strict: true, - eventName: 'IntentPublished', - logs: receipt.logs, - }); - - return { - success: true, - transactionHash: hash, - intentHash: intentPublishEvent.args.intentHash, - }; - } else { - return { - success: false, - error: 'Transaction failed', - }; - } - } catch (error: unknown) { - logger.stopSpinner(); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { - success: false, - error: errorMessage, - }; - } - } - - async getBalance(address: string, chainId?: bigint): Promise { - // Use the provided chainId to get the correct chain configuration - // If no chainId is provided, default to mainnet (though this shouldn't happen in normal usage) - const chain = chainId ? this.getChain(chainId) : chains.mainnet; - - const publicClient = createPublicClient({ - chain, - transport: http(this.rpcUrl), - }); - - return await publicClient.getBalance({ address: address as Address }); - } - - async validate( - intent: Intent, - senderAddress: string - ): Promise<{ valid: boolean; error?: string }> { - try { - const chain = this.getChain(intent.sourceChainId); - const publicClient = createPublicClient({ - chain, - transport: http(this.rpcUrl), - }); - - // Check if sender has enough balance for reward native amount on the source chain - if (intent.reward.nativeAmount > 0n) { - const balance = await this.getBalance(senderAddress, intent.sourceChainId); - - if (balance < intent.reward.nativeAmount) { - return { - valid: false, - error: `Insufficient native balance. Required: ${intent.reward.nativeAmount}, Available: ${balance}`, - }; - } - } - - // Check token balances - for (const token of intent.reward.tokens) { - const tokenAddress = AddressNormalizer.denormalizeToEvm(token.token); - - const tokenBalance = await publicClient.readContract({ - address: tokenAddress, - abi: erc20Abi, - functionName: 'balanceOf', - args: [senderAddress as Address], - }); - - if (tokenBalance < token.amount) { - return { - valid: false, - error: `Insufficient token balance for ${tokenAddress}. Required: ${token.amount}, Available: ${tokenBalance}`, - }; - } - } - - return { valid: true }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Validation failed'; - return { - valid: false, - error: errorMessage, - }; - } - } - - private getChain(chainId: bigint) { - const id = Number(chainId); - - // Find viem chain by ID - const viemChain = Object.values(chains).find((chain: Chain) => chain.id === id); - - if (!viemChain) { - throw new Error( - `Chain ID ${id} is not supported. Please use a chain that exists in viem/chains. ` + - `Popular chains include: Ethereum (1), Optimism (10), Base (8453), Arbitrum (42161), Polygon (137), BSC (56).` - ); - } - - return viemChain; - } -} diff --git a/src/blockchain/evm/evm-chain-handler.ts b/src/blockchain/evm/evm-chain-handler.ts new file mode 100644 index 0000000..60d68e6 --- /dev/null +++ b/src/blockchain/evm/evm-chain-handler.ts @@ -0,0 +1,29 @@ +import type { ChainHandler } from '@/blockchain/chain-handler.interface'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { EvmAddressSchema } from '@/blockchain/validation'; +import { RoutesCliError } from '@/shared/errors'; +import { BlockchainAddress, ChainType, EvmAddress, UniversalAddress } from '@/shared/types'; + +export class EvmChainHandler implements ChainHandler { + readonly chainType = ChainType.EVM; + + validateAddress(address: string): boolean { + return EvmAddressSchema.safeParse(address).success; + } + + normalize(address: string): UniversalAddress { + const result = EvmAddressSchema.safeParse(address); + if (!result.success) { + throw RoutesCliError.invalidAddress(address, 'EVM'); + } + return AddressNormalizer.normalizeEvm(address as EvmAddress); + } + + denormalize(address: UniversalAddress): BlockchainAddress { + return AddressNormalizer.denormalizeToEvm(address); + } + + getAddressFormat(): string { + return '0x followed by 40 hex characters (e.g., 0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b)'; + } +} diff --git a/src/blockchain/evm/evm-client-factory.ts b/src/blockchain/evm/evm-client-factory.ts new file mode 100644 index 0000000..c2142f9 --- /dev/null +++ b/src/blockchain/evm/evm-client-factory.ts @@ -0,0 +1,48 @@ +/** + * EVM Client Factory + * + * Injectable factory for creating viem clients, enabling dependency injection + * in EvmPublisher for testability without live RPC connections. + */ + +import { + Account, + Chain, + createPublicClient, + createWalletClient, + http, + type PublicClient, + Transport, + type WalletClient, +} from 'viem'; + +export interface EvmClientFactory { + createPublicClient(config: { chain: Chain; rpcUrl: string }): PublicClient; + createWalletClient(config: { + chain: Chain; + rpcUrl: string; + account: Account; + }): WalletClient; +} + +export class DefaultEvmClientFactory implements EvmClientFactory { + createPublicClient({ chain, rpcUrl }: { chain: Chain; rpcUrl: string }): PublicClient { + return createPublicClient({ chain, transport: http(rpcUrl) }) as PublicClient; + } + + createWalletClient({ + chain, + rpcUrl, + account, + }: { + chain: Chain; + rpcUrl: string; + account: Account; + }): WalletClient { + return createWalletClient({ + account, + chain, + transport: http(rpcUrl), + }) as WalletClient; + } +} diff --git a/src/blockchain/evm/evm.publisher.ts b/src/blockchain/evm/evm.publisher.ts new file mode 100644 index 0000000..a1d6385 --- /dev/null +++ b/src/blockchain/evm/evm.publisher.ts @@ -0,0 +1,333 @@ +/** + * EVM Chain Publisher (NestJS injectable) + */ + +import { Injectable } from '@nestjs/common'; + +import { + Account, + Address, + Chain, + encodeFunctionData, + erc20Abi, + Hex, + maxUint256, + parseEventLogs, + type PublicClient, + Transport, + type WalletClient, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import * as chains from 'viem/chains'; + +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { portalAbi } from '@/commons/abis/portal.abi'; +import { KeyHandle } from '@/shared/security'; +import { ChainConfig, Intent, UniversalAddress } from '@/shared/types'; +import { logger } from '@/utils/logger'; + +import { BasePublisher, IntentStatus, PublishResult, ValidationResult } from '../base.publisher'; +import { ChainRegistryService } from '../chain-registry.service'; +import { ChainsService } from '../chains.service'; + +import { DefaultEvmClientFactory, EvmClientFactory } from './evm-client-factory'; + +@Injectable() +export class EvmPublisher extends BasePublisher { + private readonly clientFactory: EvmClientFactory; + private _publicClients: Map = new Map(); + + constructor( + rpcUrl: string, + registry: ChainRegistryService, + private readonly chains: ChainsService, + clientFactory: EvmClientFactory = new DefaultEvmClientFactory() + ) { + super(rpcUrl, registry); + this.clientFactory = clientFactory; + } + + override async publish( + source: bigint, + destination: bigint, + reward: Intent['reward'], + encodedRoute: string, + keyHandle: KeyHandle, + portalAddress?: UniversalAddress, + proverAddress?: UniversalAddress + ): Promise { + this.runPreflightChecks(source); + return keyHandle.useAsync(async rawKey => { + const account = privateKeyToAccount(rawKey as Hex); + return this.runSafely(async () => { + const chain = this.getChain(source); + + const walletClient: WalletClient = + this.clientFactory.createWalletClient({ + chain, + rpcUrl: this.rpcUrl, + account, + }); + + const publicClient = this.getPublicClient(chain); + + const sourceChainConfig = this.chains.findChainById(source); + const destinationChainConfig = this.chains.findChainById(destination); + + const portalAddrUniversal = portalAddress ?? sourceChainConfig?.portalAddress; + + if (!portalAddrUniversal) { + throw new Error(`No Portal address configured for chain ${source}`); + } + + const finalPortalAddress = AddressNormalizer.denormalizeToEvm(portalAddrUniversal); + + if (!destinationChainConfig) { + throw new Error(`Destination chain is not configured ${destination}`); + } + + if (reward.nativeAmount > 0n) { + logger.spinner('Checking native balance...'); + const balance = await publicClient.getBalance({ + address: account.address, + }); + + if (balance < reward.nativeAmount) { + logger.fail( + `Insufficient native balance. Required: ${reward.nativeAmount}, Available: ${balance}` + ); + throw new Error( + `Insufficient native balance. Required: ${reward.nativeAmount}, Available: ${balance}` + ); + } + logger.succeed(`Native balance sufficient: ${balance} wei`); + } + + if (reward.tokens.length > 0) { + logger.info('Checking token balances and approvals...'); + } + + for (let i = 0; i < reward.tokens.length; i++) { + const token = reward.tokens[i]; + const tokenAddress = AddressNormalizer.denormalizeToEvm(token.token); + + logger.spinner( + `Checking balance for token ${i + 1}/${reward.tokens.length}: ${tokenAddress}` + ); + + const tokenBalance = await publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [account.address], + }); + + if (tokenBalance < token.amount) { + logger.fail(`Insufficient token balance for ${tokenAddress}`); + throw new Error( + `Insufficient token balance for ${tokenAddress}. Required: ${token.amount}, Available: ${tokenBalance}` + ); + } + + logger.succeed(`Token balance sufficient: ${tokenBalance}`); + + const allowance = await publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'allowance', + args: [account.address, finalPortalAddress], + }); + + if (allowance < token.amount) { + logger.spinner(`Approving token ${tokenAddress}...`); + + const approveTx = await walletClient.writeContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'approve', + args: [finalPortalAddress, maxUint256], + }); + + logger.updateSpinner('Waiting for approval confirmation...'); + const approvalReceipt = await publicClient.waitForTransactionReceipt({ + hash: approveTx, + confirmations: 2, + }); + + if (approvalReceipt.status !== 'success') { + logger.fail(`Token approval failed for ${tokenAddress}`); + throw new Error(`Token approval failed for ${tokenAddress}`); + } + + logger.succeed(`Token approved: ${tokenAddress}`); + } else { + logger.info(`Token already approved: ${tokenAddress}`); + } + } + + const evmReward = { + deadline: reward.deadline, + nativeAmount: reward.nativeAmount, + creator: AddressNormalizer.denormalizeToEvm(reward.creator), + prover: AddressNormalizer.denormalizeToEvm(proverAddress ?? reward.prover), + tokens: reward.tokens.map(t => ({ + token: AddressNormalizer.denormalizeToEvm(t.token), + amount: t.amount, + })), + }; + + const data = encodeFunctionData({ + abi: portalAbi, + functionName: 'publishAndFund', + args: [destination, encodedRoute as Hex, evmReward, false], + }); + + logger.spinner('Publishing intent to Portal contract...'); + const hash = await walletClient.sendTransaction({ + to: finalPortalAddress, + data, + value: reward.nativeAmount, + }); + + logger.updateSpinner('Waiting for transaction confirmation...'); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + logger.succeed('Transaction confirmed'); + + if (receipt.status === 'success') { + const [intentPublishEvent] = parseEventLogs({ + abi: portalAbi, + strict: true, + eventName: 'IntentPublished', + logs: receipt.logs, + }); + + return { + success: true, + transactionHash: hash, + intentHash: intentPublishEvent.args.intentHash, + }; + } else { + return { + success: false, + error: 'Transaction failed', + }; + } + }); + }); + } + + override async getBalance(address: string, chainId?: bigint): Promise { + const chain = chainId ? this.getChain(chainId) : this.getChain(BigInt(chains.mainnet.id)); + return await this.getPublicClient(chain).getBalance({ address: address as Address }); + } + + override async validate( + reward: Intent['reward'], + senderAddress: string, + chainId: bigint + ): Promise { + const errors: string[] = []; + try { + const publicClient = this.getPublicClient(this.getChain(chainId)); + + if (reward.nativeAmount > 0n) { + const balance = await publicClient.getBalance({ address: senderAddress as Address }); + if (balance < reward.nativeAmount) { + errors.push( + `Insufficient native balance. Required: ${reward.nativeAmount}, Available: ${balance}` + ); + } + } + + for (const token of reward.tokens) { + const tokenAddress = AddressNormalizer.denormalizeToEvm(token.token); + const tokenBalance = await publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [senderAddress as Address], + }); + if (tokenBalance < token.amount) { + errors.push( + `Insufficient token balance for ${tokenAddress}. Required: ${token.amount}, Available: ${tokenBalance}` + ); + } + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Validation failed'; + errors.push(message); + } + return { valid: errors.length === 0, errors }; + } + + override async getStatus( + intentHash: string, + chain: ChainConfig, + portalAddress?: UniversalAddress + ): Promise { + const resolvedPortal = portalAddress ?? chain.portalAddress; + if (!resolvedPortal) { + throw new Error(`No portal address configured for chain ${chain.id}`); + } + + const evmPortalAddress = AddressNormalizer.denormalizeToEvm(resolvedPortal); + const viemChain = this.getChain(chain.id); + const publicClient = this.getPublicClient(viemChain); + + try { + const currentBlock = await publicClient.getBlockNumber(); + + const events = await publicClient.getContractEvents({ + address: evmPortalAddress, + abi: portalAbi, + eventName: 'IntentFulfilled', + fromBlock: currentBlock - 1_000n, + args: { intentHash: intentHash as Hex }, + }); + + const event = events[0]; + if (!event) { + return { fulfilled: false }; + } + + const status: IntentStatus = { + fulfilled: true, + solver: AddressNormalizer.denormalizeToEvm(event.args.claimant as UniversalAddress), + fulfillmentTxHash: event.transactionHash ?? undefined, + blockNumber: event.blockNumber ?? undefined, + }; + + if (event.blockNumber) { + const block = await publicClient.getBlock({ blockNumber: event.blockNumber }); + status.timestamp = Number(block.timestamp); + } + + return status; + } catch (error) { + console.error(error); + return { fulfilled: false }; + } + } + + private getPublicClient(chain: Chain): PublicClient { + const cached = this._publicClients.get(chain.id); + if (cached) return cached; + const client = this.clientFactory.createPublicClient({ chain, rpcUrl: this.rpcUrl }); + this._publicClients.set(chain.id, client); + return client; + } + + private getChain(chainId: bigint): Chain { + const id = Number(chainId); + const viemChain = Object.values(chains).find((chain: Chain) => chain.id === id); + + if (!viemChain) { + throw new Error( + `Chain ID ${id} is not supported. Please use a chain that exists in viem/chains. ` + + `Popular chains include: Ethereum (1), Optimism (10), Base (8453), Arbitrum (42161), Polygon (137), BSC (56).` + ); + } + + return viemChain; + } +} diff --git a/src/blockchain/publisher-factory.service.ts b/src/blockchain/publisher-factory.service.ts new file mode 100644 index 0000000..9089469 --- /dev/null +++ b/src/blockchain/publisher-factory.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; + +import { ChainType } from '@/shared/types'; +import { ChainConfig } from '@/shared/types'; + +import { EvmPublisher } from './evm/evm.publisher'; +import { SvmPublisher } from './svm/svm.publisher'; +import { TvmPublisher } from './tvm/tvm.publisher'; +import { BasePublisher } from './base.publisher'; +import { ChainRegistryService } from './chain-registry.service'; +import { ChainsService } from './chains.service'; +import { RpcService } from './rpc.service'; + +@Injectable() +export class PublisherFactory { + constructor( + private readonly registry: ChainRegistryService, + private readonly rpcService: RpcService, + private readonly chains: ChainsService + ) {} + + create(chain: ChainConfig): BasePublisher { + const rpcUrl = this.rpcService.getUrl(chain); + switch (chain.type) { + case ChainType.EVM: + return new EvmPublisher(rpcUrl, this.registry, this.chains); + case ChainType.TVM: + return new TvmPublisher(rpcUrl, this.registry, this.chains); + case ChainType.SVM: + return new SvmPublisher(rpcUrl, this.registry, this.chains); + default: + throw new Error(`Unsupported chain type: ${chain.type}`); + } + } +} diff --git a/src/blockchain/rpc.service.ts b/src/blockchain/rpc.service.ts new file mode 100644 index 0000000..1603db5 --- /dev/null +++ b/src/blockchain/rpc.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; + +import { ConfigService } from '@/config/config.service'; +import { ChainConfig, ChainType } from '@/shared/types'; + +import 'dotenv/config'; + +@Injectable() +export class RpcService { + constructor(private readonly config: ConfigService) {} + + getUrl(chain: ChainConfig): string { + // 1. Per-chain override: EVM_RPC_URL_{CHAIN_ID} (e.g. EVM_RPC_URL_8453) + // Uses process.env directly because Zod strips unknown keys during validation. + if (chain.type === ChainType.EVM) { + const perChainUrl = process.env[`EVM_RPC_URL_${chain.id}`]; + if (perChainUrl) return perChainUrl; + } + // 2. Chain-type override (TVM_RPC_URL, SVM_RPC_URL) + const envOverride = this.config.getRpcUrl(chain.type, 'primary'); + // 3. Hardcoded chain default + return envOverride || chain.rpcUrl; + } + + getFallbackUrl(chain: ChainConfig): string | undefined { + return this.config.getRpcUrl(chain.type, 'fallback') || undefined; + } + + async withFallback(primary: () => Promise, fallback: () => Promise): Promise { + try { + return await primary(); + } catch { + return fallback(); + } + } +} diff --git a/src/blockchain/svm-publisher.ts b/src/blockchain/svm-publisher.ts deleted file mode 100644 index 2f07778..0000000 --- a/src/blockchain/svm-publisher.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * SVM (Solana) Chain Publisher - Refactored for maintainability - * Main publisher class that orchestrates Solana-specific intent publishing - */ - -import { Connection, Keypair, PublicKey } from '@solana/web3.js'; -import { Hex } from 'viem'; - -import { PortalHashUtils } from '@/commons/utils/portal-hash.utils'; -import { getChainById } from '@/config/chains'; -import { ChainType, Intent } from '@/core/interfaces/intent'; -import { UniversalAddress } from '@/core/types/universal-address'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; -import { logger } from '@/utils/logger'; - -import { SVM_CONNECTION_CONFIG, SVM_ERROR_MESSAGES, SVM_LOG_MESSAGES } from './svm/svm-constants'; -import { executeFunding } from './svm/svm-transaction'; -import { PublishContext, SvmError, SvmErrorType } from './svm/svm-types'; -import { BasePublisher, PublishResult } from './base-publisher'; - -export class SvmPublisher extends BasePublisher { - private connection: Connection; - - constructor(rpcUrl: string) { - super(rpcUrl); - this.connection = new Connection(rpcUrl, SVM_CONNECTION_CONFIG); - } - - /** - * Publishes an intent to the Solana blockchain - * Simplified main method that delegates to helper functions - */ - async publish( - source: bigint, - destination: bigint, - reward: Intent['reward'], - encodedRoute: string, - privateKey: string, - portalAddress?: UniversalAddress - ): Promise { - try { - // Parse private key and validate configuration - const keypair = this.parsePrivateKey(privateKey); - const portalProgramId = portalAddress - ? new PublicKey(AddressNormalizer.denormalize(portalAddress, ChainType.SVM)) - : this.getPortalProgramId(source); - - // Calculate hashes - const { intentHash, routeHash } = PortalHashUtils.getIntentHashFromReward( - source, - destination, - encodedRoute as Hex, - reward - ); - - // Log initial information - this.logPublishInfo(portalProgramId, keypair, destination); - - // Create publish context for all operations - const context: PublishContext = { - source, - destination, - reward, - encodedRoute, - privateKey, - intentHash, - routeHash, - keypair, - portalProgramId, - }; - - // Execute funding (tokens must be present) - const fundingResult = await this.fundIntent(context); - - if (fundingResult.success) { - logger.info(SVM_LOG_MESSAGES.FUNDING_SUCCESS(fundingResult.transactionHash!)); - } - - return fundingResult; - } catch (error: any) { - return this.handleError(error); - } - } - - /** - * Funds an intent if reward tokens are present - */ - private async fundIntent(context: PublishContext): Promise { - // Funding requires tokens in reward - if (context.reward.tokens.length === 0) { - const errorMsg = 'Cannot fund intent: No reward tokens specified'; - logger.error(errorMsg); - return { - success: false, - error: errorMsg, - }; - } - - try { - const fundingResult = await executeFunding(this.connection, context); - - if (!fundingResult.success) { - logger.error(`Funding failed: ${fundingResult.error}`); - return fundingResult; - } - - logger.info(SVM_LOG_MESSAGES.FUNDING_SUCCESS(fundingResult.transactionHash!)); - return fundingResult; - } catch (error: any) { - if (error instanceof SvmError) { - return { - success: false, - error: error.message, - }; - } - throw error; - } - } - - /** - * Gets the native SOL balance for an address - */ - async getBalance(address: string, _chainId?: bigint): Promise { - try { - const publicKey = new PublicKey(address); - const balance = await this.connection.getBalance(publicKey); - return BigInt(balance); - } catch { - return 0n; - } - } - - /** - * Parses a private key in various formats (Base58, array, comma-separated) - */ - private parsePrivateKey(privateKey: string): Keypair { - try { - // Array format: [1,2,3,...] - if (privateKey.startsWith('[') && privateKey.endsWith(']')) { - const bytes = JSON.parse(privateKey); - return Keypair.fromSecretKey(new Uint8Array(bytes)); - } - - // Comma-separated format: 1,2,3,... - if (privateKey.includes(',')) { - const bytes = privateKey.split(',').map(b => parseInt(b.trim())); - return Keypair.fromSecretKey(new Uint8Array(bytes)); - } - - // Base58 format (default) - // eslint-disable-next-line @typescript-eslint/no-require-imports - const bs58 = require('bs58'); - const bytes = bs58.decode(privateKey); - return Keypair.fromSecretKey(bytes); - } catch (error: any) { - throw new SvmError( - SvmErrorType.INVALID_CONFIG, - SVM_ERROR_MESSAGES.INVALID_PRIVATE_KEY, - error - ); - } - } - - /** - * Gets the Portal program ID for a given chain - */ - private getPortalProgramId(chainId: bigint): PublicKey { - const chainConfig = getChainById(chainId); - - if (!chainConfig?.portalAddress) { - throw new SvmError( - SvmErrorType.INVALID_CONFIG, - SVM_ERROR_MESSAGES.NO_PORTAL_ADDRESS(chainId) - ); - } - - return new PublicKey(AddressNormalizer.denormalize(chainConfig.portalAddress, ChainType.SVM)); - } - - /** - * Logs initial publishing information - */ - private logPublishInfo(portalProgramId: PublicKey, keypair: Keypair, destination: bigint): void { - logger.info(SVM_LOG_MESSAGES.PORTAL_PROGRAM(portalProgramId.toString())); - logger.info(SVM_LOG_MESSAGES.CREATOR(keypair.publicKey.toString())); - logger.info(SVM_LOG_MESSAGES.DESTINATION_CHAIN(destination)); - } - - /** - * Handles errors with proper formatting and logging - */ - private handleError(error: any): PublishResult { - logger.stopSpinner(); - - let errorMessage = error.message || 'Unknown error'; - - // Add additional error context if available - if (error.logs) { - errorMessage += `\nLogs: ${error.logs.join('\n')}`; - } - if (error.err) { - errorMessage += `\nError: ${JSON.stringify(error.err)}`; - } - if (error.details) { - errorMessage += `\nDetails: ${JSON.stringify(error.details)}`; - } - - logger.error(`Transaction failed: ${errorMessage}`); - - return { - success: false, - error: errorMessage, - }; - } -} diff --git a/src/blockchain/svm/pda-manager.ts b/src/blockchain/svm/pda-manager.ts new file mode 100644 index 0000000..88546b4 --- /dev/null +++ b/src/blockchain/svm/pda-manager.ts @@ -0,0 +1,24 @@ +/** + * SVM PDA Manager + * Consolidates all Program Derived Address (PDA) derivations for Solana operations. + * All PDA seeds and derivation logic lives here. + */ + +import { PublicKey } from '@solana/web3.js'; + +import { createPdaSeedBuffer, hexToBuffer } from './svm-buffer-utils'; +import { SVM_PDA_SEEDS } from './svm-constants'; + +/** + * Calculates the vault PDA for an intent. + * Vault PDA: ["vault", intent_hash] — stores reward tokens. + */ +export function calculateVaultPDA(intentHash: string, portalProgramId: PublicKey): PublicKey { + const intentHashBytes = hexToBuffer(intentHash); + const [vaultPda] = PublicKey.findProgramAddressSync( + [createPdaSeedBuffer(SVM_PDA_SEEDS.VAULT), intentHashBytes], + portalProgramId + ); + + return vaultPda; +} diff --git a/src/blockchain/svm/solana-client.ts b/src/blockchain/svm/solana-client.ts new file mode 100644 index 0000000..89501bb --- /dev/null +++ b/src/blockchain/svm/solana-client.ts @@ -0,0 +1,39 @@ +/** + * Solana Client + * Wraps Solana Connection setup and Anchor program initialization. + * Provides the injectable SvmClientFactory interface for testability. + */ + +import { AnchorProvider, Program, Wallet } from '@coral-xyz/anchor'; +import { Connection } from '@solana/web3.js'; + +import { getPortalIdl } from '@/commons/idls/portal.idl'; + +import { SVM_CONNECTION_CONFIG, SVM_PROVIDER_CONFIG } from './svm-constants'; +import { AnchorSetupResult, PublishContext } from './svm-types'; + +export interface SvmClientFactory { + createConnection(rpcUrl: string): Connection; +} + +export class DefaultSvmClientFactory implements SvmClientFactory { + createConnection(rpcUrl: string): Connection { + return new Connection(rpcUrl, SVM_CONNECTION_CONFIG); + } +} + +/** + * Sets up Anchor provider and program for Solana interactions. + */ +export function setupAnchorProgram( + connection: Connection, + context: PublishContext +): AnchorSetupResult { + const wallet = new Wallet(context.keypair); + const provider = new AnchorProvider(connection, wallet, SVM_PROVIDER_CONFIG); + + const idl = getPortalIdl(context.portalProgramId.toBase58()); + const program = new Program(idl, provider); + + return { program, provider }; +} diff --git a/src/blockchain/svm/svm-chain-handler.ts b/src/blockchain/svm/svm-chain-handler.ts new file mode 100644 index 0000000..4a11b69 --- /dev/null +++ b/src/blockchain/svm/svm-chain-handler.ts @@ -0,0 +1,29 @@ +import type { ChainHandler } from '@/blockchain/chain-handler.interface'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { SvmAddressSchema } from '@/blockchain/validation'; +import { RoutesCliError } from '@/shared/errors'; +import { BlockchainAddress, ChainType, SvmAddress, UniversalAddress } from '@/shared/types'; + +export class SvmChainHandler implements ChainHandler { + readonly chainType = ChainType.SVM; + + validateAddress(address: string): boolean { + return SvmAddressSchema.safeParse(address).success; + } + + normalize(address: string): UniversalAddress { + const result = SvmAddressSchema.safeParse(address); + if (!result.success) { + throw RoutesCliError.invalidAddress(address, 'SVM'); + } + return AddressNormalizer.normalizeSvm(address as SvmAddress); + } + + denormalize(address: UniversalAddress): BlockchainAddress { + return AddressNormalizer.denormalizeToSvm(address); + } + + getAddressFormat(): string { + return 'Base58 public key, 32–44 characters (e.g., So11111111111111111111111111111111111111112)'; + } +} diff --git a/src/blockchain/svm/svm-client-factory.ts b/src/blockchain/svm/svm-client-factory.ts new file mode 100644 index 0000000..d36d33e --- /dev/null +++ b/src/blockchain/svm/svm-client-factory.ts @@ -0,0 +1,5 @@ +/** + * @deprecated Import directly from './solana-client' for new code. + * Re-exported here for backward compatibility with publisher-factory.ts and test mocks. + */ +export { DefaultSvmClientFactory, SvmClientFactory } from './solana-client'; diff --git a/src/blockchain/svm/svm-decode.ts b/src/blockchain/svm/svm-decode.ts index 8c27a1e..e060c3f 100644 --- a/src/blockchain/svm/svm-decode.ts +++ b/src/blockchain/svm/svm-decode.ts @@ -36,8 +36,9 @@ export async function decodeTransactionLogs( const events = parseEventsFromLogs(logs, program); return events; - } catch (error: any) { - logger.warn(`Failed to decode transaction logs: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to decode transaction logs: ${message}`); // Return empty array if decoding fails - this is non-critical return []; } @@ -61,8 +62,9 @@ function parseEventsFromLogs(logs: string[], program: Program): DecodedEvent[] { } return events; - } catch (error: any) { - logger.warn(`Failed to parse events from logs: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to parse events from logs: ${message}`); return []; } } @@ -88,44 +90,64 @@ export async function extractIntentPublishedEvent( // Transform the event data to our format return transformIntentPublishedEvent(intentPublishedEvent.data); - } catch (error: any) { - logger.warn(`Failed to extract IntentPublished event: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to extract IntentPublished event: ${message}`); return null; } } +/** Shape of the raw Anchor IntentPublished event data before transformation. */ +interface RawIntentPublishedData { + intentHash?: number[] | string; + destination?: { toString(): string } | string | number; + route?: number[] | string; + reward?: { + deadline?: { toString(): string }; + creator?: { toString(): string }; + prover?: { toString(): string }; + nativeAmount?: { toString(): string }; + tokens?: Array<{ + token?: { toString(): string }; + amount?: { toString(): string }; + }>; + }; +} + /** * Transforms raw event data to DecodedIntentPublished format */ -function transformIntentPublishedEvent(eventData: any): DecodedIntentPublished { +function transformIntentPublishedEvent(eventData: Record): DecodedIntentPublished { try { + const data = eventData as RawIntentPublishedData; + // Convert intent_hash array to hex string if it's an array - const intentHash = Array.isArray(eventData.intentHash) - ? bufferToHex(Buffer.from(eventData.intentHash)) - : eventData.intentHash; + const intentHash = Array.isArray(data.intentHash) + ? bufferToHex(Buffer.from(data.intentHash as number[])) + : (data.intentHash as string) || ''; // Convert route bytes to hex string if it's an array - const route = Array.isArray(eventData.route) - ? bufferToHex(Buffer.from(eventData.route)) - : eventData.route; + const route = Array.isArray(data.route) + ? bufferToHex(Buffer.from(data.route as number[])) + : (data.route as string) || ''; return { intentHash, - destination: eventData.destination?.toString() || '', + destination: data.destination?.toString() || '', route, reward: { - deadline: eventData.reward?.deadline?.toString() || '0', - creator: eventData.reward?.creator?.toString() || '', - prover: eventData.reward?.prover?.toString() || '', - nativeAmount: eventData.reward?.nativeAmount?.toString() || '0', + deadline: data.reward?.deadline?.toString() || '0', + creator: data.reward?.creator?.toString() || '', + prover: data.reward?.prover?.toString() || '', + nativeAmount: data.reward?.nativeAmount?.toString() || '0', tokens: - eventData.reward?.tokens?.map((token: any) => ({ + data.reward?.tokens?.map(token => ({ token: token.token?.toString() || '', amount: token.amount?.toString() || '0', })) || [], }, }; - } catch (error: any) { + } catch (error: unknown) { throw new SvmError( SvmErrorType.TRANSACTION_FAILED, 'Failed to transform IntentPublished event', @@ -137,18 +159,19 @@ function transformIntentPublishedEvent(eventData: any): DecodedIntentPublished { /** * Decodes instruction data from a transaction */ -export async function decodeInstructionData( - program: Program, - instructionData: Buffer, +export function decodeInstructionData( + _program: Program, + _instructionData: Buffer, instructionName: string -): Promise { +): { name: string } | null { try { // For now, we'll just log the instruction name // Actual decoding would require the instruction discriminator logger.info(`Processing ${instructionName} instruction`); return { name: instructionName }; - } catch (error: any) { - logger.warn(`Failed to decode instruction data: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to decode instruction data: ${message}`); return null; } } @@ -189,8 +212,8 @@ export async function logTransactionDetails( logger.info(` Event ${index + 1}: ${event.name}`); if ('intentHash' in event.data) - event.data.intentHash = arrayToHex(event.data.intentHash[0]); - if ('route' in event.data) event.data.route = bufferToHex(event.data.route); + event.data.intentHash = arrayToHex((event.data.intentHash as number[][])[0]); + if ('route' in event.data) event.data.route = bufferToHex(event.data.route as Buffer); logger.info(` Data: ${JSON.stringify(event.data, null, 2)}`); }); @@ -200,7 +223,8 @@ export async function logTransactionDetails( if (parsedTx.meta?.err) { logger.error(`Transaction Error: ${JSON.stringify(parsedTx.meta.err)}`); } - } catch (error: any) { - logger.warn(`Failed to log transaction details: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to log transaction details: ${message}`); } } diff --git a/src/blockchain/svm/svm-types.ts b/src/blockchain/svm/svm-types.ts index 3f2bfed..8c0beeb 100644 --- a/src/blockchain/svm/svm-types.ts +++ b/src/blockchain/svm/svm-types.ts @@ -3,11 +3,12 @@ * Provides type safety and clear interfaces for Solana-specific operations */ -import { BN } from '@coral-xyz/anchor'; -import { Commitment, Keypair, PublicKey } from '@solana/web3.js'; +import { AnchorProvider, BN, Program } from '@coral-xyz/anchor'; +import { Commitment, Keypair, PublicKey, Transaction } from '@solana/web3.js'; import { Hex } from 'viem'; -import { Intent } from '@/core/interfaces/intent'; +import { Intent } from '@/shared/types'; +import { UniversalAddress } from '@/shared/types'; /** * Solana-specific portal reward format @@ -67,11 +68,12 @@ export interface PublishContext { destination: bigint; reward: Intent['reward']; encodedRoute: string; - privateKey: string; intentHash: string; routeHash: Hex; keypair: Keypair; portalProgramId: PublicKey; + /** Optional prover address override — uses reward.prover when absent */ + proverAddress?: UniversalAddress; } /** @@ -88,15 +90,15 @@ export interface TokenAccountResult { * Anchor program setup result */ export interface AnchorSetupResult { - program: any; // Program type from Anchor - provider: any; // AnchorProvider type + program: Program; + provider: AnchorProvider; } /** * Transaction building result */ export interface TransactionBuildResult { - transaction: any; // Transaction type from Solana + transaction: Transaction; signers: Keypair[]; } @@ -119,7 +121,7 @@ export class SvmError extends Error { constructor( public readonly type: SvmErrorType, message: string, - public readonly details?: any + public readonly details?: unknown ) { super(message); this.name = 'SvmError'; @@ -140,7 +142,7 @@ export interface TransactionSendOptions { */ export interface DecodedEvent { name: string; - data: any; + data: Record; } /** diff --git a/src/blockchain/svm/svm.publisher.ts b/src/blockchain/svm/svm.publisher.ts new file mode 100644 index 0000000..0f4da67 --- /dev/null +++ b/src/blockchain/svm/svm.publisher.ts @@ -0,0 +1,229 @@ +/** + * SVM (Solana) Chain Publisher (NestJS injectable) + */ + +import { Injectable } from '@nestjs/common'; + +import { getAccount, getAssociatedTokenAddressSync } from '@solana/spl-token'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import { Hex } from 'viem'; + +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { PortalHashUtils } from '@/commons/utils/portal-hash.utils'; +import { KeyHandle } from '@/shared/security'; +import { ChainConfig, ChainType, Intent, UniversalAddress } from '@/shared/types'; +import { logger } from '@/utils/logger'; + +import { BasePublisher, IntentStatus, PublishResult, ValidationResult } from '../base.publisher'; +import { ChainRegistryService } from '../chain-registry.service'; +import { ChainsService } from '../chains.service'; + +import { DefaultSvmClientFactory, SvmClientFactory } from './solana-client'; +import { PublishContext, SvmError, SvmErrorType } from './svm-types'; +import { executeFunding } from './transaction-builder'; + +@Injectable() +export class SvmPublisher extends BasePublisher { + private connection: Connection; + + constructor( + rpcUrl: string, + registry: ChainRegistryService, + private readonly chains: ChainsService, + factory: SvmClientFactory = new DefaultSvmClientFactory() + ) { + super(rpcUrl, registry); + this.connection = factory.createConnection(rpcUrl); + } + + override async publish( + source: bigint, + destination: bigint, + reward: Intent['reward'], + encodedRoute: string, + keyHandle: KeyHandle, + portalAddress?: UniversalAddress, + proverAddress?: UniversalAddress + ): Promise { + this.runPreflightChecks(source); + return keyHandle.useAsync(async rawKey => { + const keypair = this.parsePrivateKey(rawKey); + return this.runSafely(async () => { + const portalProgramId = portalAddress + ? new PublicKey(AddressNormalizer.denormalize(portalAddress, ChainType.SVM)) + : this.getPortalProgramId(source); + + const { intentHash, routeHash } = PortalHashUtils.getIntentHashFromReward( + source, + destination, + encodedRoute as Hex, + reward + ); + + this.logPublishInfo(portalProgramId, keypair, destination); + + const context: PublishContext = { + source, + destination, + reward, + encodedRoute, + intentHash, + routeHash, + keypair, + portalProgramId, + proverAddress, + }; + + const fundingResult = await this.fundIntent(context); + + if (fundingResult.success) { + logger.info(`Funding successful: ${fundingResult.transactionHash!}`); + } + + return fundingResult; + }); + }); + } + + private async fundIntent(context: PublishContext): Promise { + if (context.reward.tokens.length === 0) { + const errorMsg = 'Cannot fund intent: No reward tokens specified'; + logger.error(errorMsg); + return { success: false, error: errorMsg }; + } + + try { + const fundingResult = await executeFunding(this.connection, context); + + if (!fundingResult.success) { + logger.error(`Funding failed: ${fundingResult.error}`); + return fundingResult; + } + + logger.info(`Funding successful: ${fundingResult.transactionHash!}`); + return fundingResult; + } catch (error: unknown) { + if (error instanceof SvmError) { + return { success: false, error: error.message }; + } + throw error; + } + } + + override async getBalance(address: string, _chainId?: bigint): Promise { + try { + const publicKey = new PublicKey(address); + const balance = await this.connection.getBalance(publicKey); + return BigInt(balance); + } catch { + return 0n; + } + } + + override async validate( + reward: Intent['reward'], + senderAddress: string, + _chainId: bigint + ): Promise { + const errors: string[] = []; + + if (reward.nativeAmount > 0n) { + const balance = await this.getBalance(senderAddress); + if (balance < reward.nativeAmount) { + errors.push( + `Insufficient SOL balance. Required: ${reward.nativeAmount} lamports, Available: ${balance}` + ); + } + } + + const walletPubkey = new PublicKey(senderAddress); + for (const token of reward.tokens) { + try { + const tokenMint = new PublicKey(AddressNormalizer.denormalize(token.token, ChainType.SVM)); + const ata = getAssociatedTokenAddressSync(tokenMint, walletPubkey); + const tokenAccount = await getAccount(this.connection, ata); + if (tokenAccount.amount < token.amount) { + errors.push( + `Insufficient SPL token balance for ${tokenMint}. Required: ${token.amount}, Available: ${tokenAccount.amount}` + ); + } + } catch { + errors.push( + `Could not verify SPL token balance for ${AddressNormalizer.denormalize(token.token, ChainType.SVM)}` + ); + } + } + + return { valid: errors.length === 0, errors }; + } + + protected override handleError(error: unknown): PublishResult { + logger.stopSpinner(); + + let errorMessage = error instanceof Error ? error.message : String(error); + + if (typeof error === 'object' && error !== null) { + const solanaError = error as { logs?: string[]; err?: unknown; details?: unknown }; + if (solanaError.logs) { + errorMessage += `\nLogs: ${solanaError.logs.join('\n')}`; + } + if (solanaError.err) { + errorMessage += `\nError: ${JSON.stringify(solanaError.err)}`; + } + if (solanaError.details) { + errorMessage += `\nDetails: ${JSON.stringify(solanaError.details)}`; + } + } + + logger.error(`Transaction failed: ${errorMessage}`); + return { success: false, error: errorMessage }; + } + + private parsePrivateKey(privateKey: string): Keypair { + try { + if (privateKey.startsWith('[') && privateKey.endsWith(']')) { + const bytes = JSON.parse(privateKey); + return Keypair.fromSecretKey(new Uint8Array(bytes)); + } + + if (privateKey.includes(',')) { + const bytes = privateKey.split(',').map(b => parseInt(b.trim())); + return Keypair.fromSecretKey(new Uint8Array(bytes)); + } + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const bs58 = require('bs58'); + const bytes = bs58.decode(privateKey); + return Keypair.fromSecretKey(bytes); + } catch (error: unknown) { + throw new SvmError(SvmErrorType.INVALID_CONFIG, 'Invalid private key format', error); + } + } + + override getStatus( + _intentHash: string, + _chain: ChainConfig, + _portalAddress?: UniversalAddress + ): Promise { + return Promise.reject(new Error('getStatus not yet implemented for SVM')); + } + + private getPortalProgramId(chainId: bigint): PublicKey { + const chainConfig = this.chains.findChainById(chainId); + + if (!chainConfig?.portalAddress) { + throw new SvmError( + SvmErrorType.INVALID_CONFIG, + `No Portal address configured for chain ${chainId}` + ); + } + + return new PublicKey(AddressNormalizer.denormalize(chainConfig.portalAddress, ChainType.SVM)); + } + + private logPublishInfo(portalProgramId: PublicKey, keypair: Keypair, destination: bigint): void { + logger.info(`Using Portal Program: ${portalProgramId.toString()}`); + logger.info(`Creator: ${keypair.publicKey.toString()}`); + logger.info(`Destination Chain: ${destination}`); + } +} diff --git a/src/blockchain/svm/svm-transaction.ts b/src/blockchain/svm/transaction-builder.ts similarity index 70% rename from src/blockchain/svm/svm-transaction.ts rename to src/blockchain/svm/transaction-builder.ts index abf9219..1b08f2b 100644 --- a/src/blockchain/svm/svm-transaction.ts +++ b/src/blockchain/svm/transaction-builder.ts @@ -1,58 +1,37 @@ /** - * SVM (Solana) Transaction Building and Management - * Handles transaction construction, sending, and confirmation for Solana + * SVM Transaction Builder + * Builds and executes Solana transactions for the Portal program. + * Depends on solana-client.ts (program setup) and pda-manager.ts (PDA derivations). */ -import { AnchorProvider, BN, Program, Wallet } from '@coral-xyz/anchor'; +import { BN, Program } from '@coral-xyz/anchor'; import { getAssociatedTokenAddress } from '@solana/spl-token'; import { Connection, Keypair, PublicKey, Transaction } from '@solana/web3.js'; -import { getPortalIdl } from '@/commons/idls/portal.idl'; -import { PortalIdl } from '@/commons/types/portal-idl.type'; -import { ChainType, Intent } from '@/core/interfaces/intent'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { ChainType, Intent } from '@/shared/types'; import { logger } from '@/utils/logger'; -import { PublishResult } from '../base-publisher'; +import { PublishResult } from '../base.publisher'; -import { createPdaSeedBuffer, hexToArray, hexToBuffer } from './svm-buffer-utils'; -import { - SVM_CONFIRMATION_CONFIG, - SVM_ERROR_MESSAGES, - SVM_LOG_MESSAGES, - SVM_PDA_SEEDS, - SVM_PROVIDER_CONFIG, -} from './svm-constants'; +import { calculateVaultPDA } from './pda-manager'; +import { setupAnchorProgram } from './solana-client'; +import { hexToArray, hexToBuffer } from './svm-buffer-utils'; +import { SVM_CONFIRMATION_CONFIG, SVM_ERROR_MESSAGES, SVM_LOG_MESSAGES } from './svm-constants'; import { extractIntentPublishedEvent, logTransactionDetails } from './svm-decode'; import { prepareTokenTransferAccounts } from './svm-token-operations'; -import { - AnchorSetupResult, - PublishContext, - SvmError, - SvmErrorType, - TransactionResultWithDecoding, -} from './svm-types'; +import { PublishContext, SvmError, SvmErrorType, TransactionResultWithDecoding } from './svm-types'; /** - * Sets up Anchor provider and program for Solana interactions + * Converts Intent reward to Solana-specific format. */ -export function setupAnchorProgram( - connection: Connection, - context: PublishContext -): AnchorSetupResult { - const wallet = new Wallet(context.keypair); - const provider = new AnchorProvider(connection, wallet, SVM_PROVIDER_CONFIG); - - const idl = getPortalIdl(context.portalProgramId.toBase58()); - const program = new Program(idl, provider); - - return { program, provider }; -} - -/** - * Converts Intent reward to Solana-specific format - */ -export function buildPortalReward(reward: Intent['reward']) { +export function buildPortalReward(reward: Intent['reward']): { + deadline: BN; + creator: PublicKey; + prover: PublicKey; + nativeAmount: BN; + tokens: { token: PublicKey; amount: BN }[]; +} { return { deadline: new BN(reward.deadline), creator: new PublicKey(AddressNormalizer.denormalize(reward.creator, ChainType.SVM)), @@ -66,20 +45,7 @@ export function buildPortalReward(reward: Intent['reward']) { } /** - * Calculates the vault PDA for an intent - */ -export function calculateVaultPDA(intentHash: string, portalProgramId: PublicKey): PublicKey { - const intentHashBytes = hexToBuffer(intentHash); - const [vaultPda] = PublicKey.findProgramAddressSync( - [createPdaSeedBuffer(SVM_PDA_SEEDS.VAULT), intentHashBytes], - portalProgramId - ); - - return vaultPda; -} - -/** - * Builds a publish transaction for Solana + * Builds a publish transaction for Solana. */ export async function buildPublishTransaction( program: Program, @@ -103,38 +69,30 @@ export async function buildPublishTransaction( } /** - * Builds a funding transaction for Solana + * Builds a funding transaction for Solana. */ export async function buildFundingTransaction( - connection: Connection, - program: Program, + _connection: Connection, + program: Program, context: PublishContext ): Promise { if (context.reward.tokens.length === 0) { throw new SvmError(SvmErrorType.INVALID_CONFIG, SVM_ERROR_MESSAGES.NO_REWARD_TOKENS); } - // Calculate vault PDA const vaultPda = calculateVaultPDA(context.intentHash, context.portalProgramId); logger.info(SVM_LOG_MESSAGES.VAULT_PDA(vaultPda.toString())); - // Get token mint and accounts const tokenMint = new PublicKey( AddressNormalizer.denormalizeToSvm(context.reward.tokens[0].token) ); const funderTokenAccount = await getAssociatedTokenAddress(tokenMint, context.keypair.publicKey); - - // Get vault token account address (must already exist) const vaultTokenAccount = await getAssociatedTokenAddress( tokenMint, vaultPda, true // allowOwnerOffCurve for PDA ); - // Build portal reward - // const portalReward = buildPortalReward(context.reward); - - // Prepare token transfer accounts const tokenTransferAccounts = prepareTokenTransferAccounts( funderTokenAccount, vaultTokenAccount, @@ -143,7 +101,6 @@ export async function buildFundingTransaction( logger.info(SVM_LOG_MESSAGES.BUILD_FUNDING_TX); - // Build the funding transaction const transaction = await program.methods .fund({ destination: new BN(context.destination), @@ -151,7 +108,9 @@ export async function buildFundingTransaction( reward: { deadline: new BN(context.reward.deadline), creator: new PublicKey(AddressNormalizer.denormalizeToSvm(context.reward.creator)), - prover: new PublicKey(AddressNormalizer.denormalizeToSvm(context.reward.prover)), + prover: new PublicKey( + AddressNormalizer.denormalizeToSvm(context.proverAddress ?? context.reward.prover) + ), nativeAmount: new BN(context.reward.nativeAmount), tokens: context.reward.tokens.map(token => ({ token: new PublicKey(AddressNormalizer.denormalizeToSvm(token.token)), @@ -168,16 +127,11 @@ export async function buildFundingTransaction( .remainingAccounts(tokenTransferAccounts) .transaction(); - // Fix route hash encoding in instruction data - // const instructionData = Buffer.from(transaction.instructions[0].data); - // copyBufferAt(hexToBuffer(context.routeHash), instructionData, 16); - // transaction.instructions[0].data = instructionData; - return transaction; } /** - * Sends and confirms a transaction on Solana + * Sends and confirms a transaction on Solana. */ export async function sendAndConfirmTransaction( connection: Connection, @@ -197,42 +151,34 @@ export async function sendAndConfirmTransaction( logger.info(SVM_LOG_MESSAGES.TX_SIGNATURE(signature)); - // Wait for confirmation await waitForTransactionConfirmation(connection, signature); - // Decode transaction data if program is provided const result: TransactionResultWithDecoding = { signature }; if (program) { try { - // Log detailed transaction information await logTransactionDetails(connection, signature, program); - - // Extract IntentPublished event if present const intentPublished = await extractIntentPublishedEvent(connection, signature, program); if (intentPublished) { result.intentPublished = intentPublished; logger.info(`Decoded IntentPublished event: ${JSON.stringify(intentPublished, null, 2)}`); } - } catch (decodeError: any) { - // Decoding is non-critical, log but don't fail - logger.warn(`Failed to decode transaction events: ${decodeError.message}`); + } catch (decodeError: unknown) { + const message = decodeError instanceof Error ? decodeError.message : String(decodeError); + logger.warn(`Failed to decode transaction events: ${message}`); } } return result; - } catch (error: any) { + } catch (error: unknown) { logger.stopSpinner(); - throw new SvmError( - SvmErrorType.TRANSACTION_FAILED, - `Transaction failed: ${error.message}`, - error - ); + const message = error instanceof Error ? error.message : String(error); + throw new SvmError(SvmErrorType.TRANSACTION_FAILED, `Transaction failed: ${message}`, error); } } /** - * Waits for transaction confirmation with improved error handling + * Waits for transaction confirmation with improved error handling. */ export async function waitForTransactionConfirmation( connection: Connection, @@ -282,7 +228,7 @@ export async function waitForTransactionConfirmation( } /** - * Executes a funding operation for an intent + * Executes a funding operation for an intent. */ export async function executeFunding( connection: Connection, @@ -308,7 +254,7 @@ export async function executeFunding( transactionHash: result.signature, intentHash: context.intentHash, }; - } catch (error: any) { + } catch (error: unknown) { logger.stopSpinner(); if (error instanceof SvmError) { @@ -320,7 +266,7 @@ export async function executeFunding( } /** - * Executes a publish operation for an intent + * Executes a publish operation for an intent. */ export async function executePublish( connection: Connection, @@ -341,7 +287,6 @@ export async function executePublish( logger.succeed(SVM_LOG_MESSAGES.PUBLISH_SUCCESS); - // Log decoded event data if available if (result.intentPublished) { logger.info('Intent Published Successfully with data:'); logger.info(` Intent Hash: ${result.intentPublished.intentHash}`); @@ -356,7 +301,7 @@ export async function executePublish( intentHash: context.intentHash, decodedData: result.intentPublished, }; - } catch (error: any) { + } catch (error: unknown) { logger.stopSpinner(); if (error instanceof SvmError) { diff --git a/src/blockchain/tvm-publisher.ts b/src/blockchain/tvm-publisher.ts deleted file mode 100644 index 34d7c40..0000000 --- a/src/blockchain/tvm-publisher.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * TVM (Tron) Chain Publisher - */ - -import { TronWeb } from 'tronweb'; -import { erc20Abi, Hex } from 'viem'; - -import { portalAbi } from '@/commons/abis/portal.abi'; -import { PortalHashUtils } from '@/commons/utils/portal-hash.utils'; -import { getChainById } from '@/config/chains'; -import { ChainType, Intent } from '@/core/interfaces/intent'; -import { UniversalAddress } from '@/core/types/universal-address'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; -import { logger } from '@/utils/logger'; - -import { BasePublisher, PublishResult } from './base-publisher'; - -export class TvmPublisher extends BasePublisher { - private tronWeb: TronWeb; - - constructor(rpcUrl: string) { - super(rpcUrl); - this.tronWeb = new TronWeb({ - fullHost: rpcUrl, - }); - } - - async publish( - source: bigint, - destination: bigint, - reward: Intent['reward'], - encodedRoute: string, - privateKey: string, - _portalAddress?: UniversalAddress - ): Promise { - try { - // Set private key - this.tronWeb.setPrivateKey(privateKey); - const senderAddress = this.tronWeb.address.fromPrivateKey(privateKey); - - // Get Portal address - const chainConfig = getChainById(source); - const portalAddrUniversal = _portalAddress ?? chainConfig?.portalAddress; - if (!portalAddrUniversal) { - throw new Error(`No Portal address configured for chain ${source}`); - } - const portalAddress = AddressNormalizer.denormalize(portalAddrUniversal, ChainType.TVM); - - // Encode route for destination chain type - const destChainConfig = getChainById(BigInt(destination)); - if (!destChainConfig) { - throw new Error(`Unknown destination chain: ${destination}`); - } - - // Get Portal contract with ABI - const sourceToken = reward.tokens[0]; - const tokenContract = this.tronWeb.contract( - erc20Abi, - AddressNormalizer.denormalizeToTvm(sourceToken.token) - ); - - logger.spinner('Approving tokens...'); - - const approvalTxId = await tokenContract - .approve(portalAddress, sourceToken.amount) - .send({ from: senderAddress }); - - logger.updateSpinner('Waiting for approval confirmation...'); - - const approvalSuccessful = await this.waitForTransaction(approvalTxId); - - if (!approvalSuccessful) { - logger.fail('Token approval failed'); - throw new Error('Approval failed'); - } - - logger.succeed('Tokens approved'); - - const portalContract = this.tronWeb.contract(portalAbi, portalAddress); - - // Prepare parameters - TronWeb expects strings for numbers - const tvmReward: Parameters[0][2] = [ - reward.deadline, - AddressNormalizer.denormalize(reward.creator, ChainType.TVM), - AddressNormalizer.denormalize(reward.prover, ChainType.TVM), - reward.nativeAmount, - reward.tokens.map( - t => [AddressNormalizer.denormalize(t.token, ChainType.TVM), t.amount] as const - ), - ]; - - // Call publish function - // Pass parameters as separate arguments - logger.spinner('Publishing intent to Portal contract...'); - const tx = await portalContract - .publishAndFund(destination, encodedRoute, tvmReward, false) - .send({ - from: senderAddress, - callValue: Number(reward.nativeAmount), // TRX amount in sun - }); - - logger.updateSpinner('Waiting for transaction confirmation...'); - - const { intentHash } = PortalHashUtils.getIntentHashFromReward( - destination, - source, - encodedRoute as Hex, - reward - ); - - if (tx) { - logger.succeed('Transaction confirmed'); - return { - success: true, - transactionHash: tx, - intentHash, - }; - } else { - logger.fail('Transaction failed'); - return { - success: false, - error: 'Transaction failed', - }; - } - } catch (error: unknown) { - logger.stopSpinner(); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { - success: false, - error: errorMessage, - }; - } - } - - async getBalance(address: string, _chainId?: bigint): Promise { - try { - const balance = await this.tronWeb.trx.getBalance(address); - return BigInt(balance); - } catch { - return 0n; - } - } - - /** - * Waits for a transaction to be confirmed on the blockchain - * @param txId - Transaction ID to wait for - * @returns true if confirmed, false if timeout - */ - async waitForTransaction(txId: string): Promise { - for (let i = 0; i < 20; i++) { - const txInfo = await this.tronWeb.trx.getTransactionInfo(txId); - if (txInfo && txInfo.blockNumber && txInfo.receipt?.result === 'SUCCESS') { - return true; - } - - if (txInfo?.receipt?.result === 'FAILED') { - throw new Error( - `Transaction failed: ${txInfo.receipt.result || 'Unknown error'}. txId: ${txId}. Received: ${JSON.stringify(txInfo.receipt)}` - ); - } - - // Wait before next attempt - await new Promise(resolve => setTimeout(resolve, 4_000)); // Wait 4s - } - return false; - } -} diff --git a/src/blockchain/tvm/tvm-chain-handler.ts b/src/blockchain/tvm/tvm-chain-handler.ts new file mode 100644 index 0000000..37b7f4f --- /dev/null +++ b/src/blockchain/tvm/tvm-chain-handler.ts @@ -0,0 +1,29 @@ +import type { ChainHandler } from '@/blockchain/chain-handler.interface'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { TvmAddressSchema } from '@/blockchain/validation'; +import { RoutesCliError } from '@/shared/errors'; +import { BlockchainAddress, ChainType, TronAddress, UniversalAddress } from '@/shared/types'; + +export class TvmChainHandler implements ChainHandler { + readonly chainType = ChainType.TVM; + + validateAddress(address: string): boolean { + return TvmAddressSchema.safeParse(address).success; + } + + normalize(address: string): UniversalAddress { + const result = TvmAddressSchema.safeParse(address); + if (!result.success) { + throw RoutesCliError.invalidAddress(address, 'TVM'); + } + return AddressNormalizer.normalizeTvm(address as TronAddress); + } + + denormalize(address: UniversalAddress): BlockchainAddress { + return AddressNormalizer.denormalizeToTvm(address); + } + + getAddressFormat(): string { + return 'Base58 starting with T, 34 characters (e.g., TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH)'; + } +} diff --git a/src/blockchain/tvm/tvm-client-factory.ts b/src/blockchain/tvm/tvm-client-factory.ts new file mode 100644 index 0000000..4e8b870 --- /dev/null +++ b/src/blockchain/tvm/tvm-client-factory.ts @@ -0,0 +1,18 @@ +/** + * TVM Client Factory + * + * Injectable factory for creating TronWeb instances, enabling dependency injection + * in TvmPublisher for testability without live RPC connections. + */ + +import { TronWeb } from 'tronweb'; + +export interface TvmClientFactory { + createClient(rpcUrl: string): TronWeb; +} + +export class DefaultTvmClientFactory implements TvmClientFactory { + createClient(rpcUrl: string): TronWeb { + return new TronWeb({ fullHost: rpcUrl }); + } +} diff --git a/src/blockchain/tvm/tvm.publisher.ts b/src/blockchain/tvm/tvm.publisher.ts new file mode 100644 index 0000000..b5a3011 --- /dev/null +++ b/src/blockchain/tvm/tvm.publisher.ts @@ -0,0 +1,204 @@ +/** + * TVM (Tron) Chain Publisher (NestJS injectable) + */ + +import { Injectable } from '@nestjs/common'; + +import { TronWeb } from 'tronweb'; +import { erc20Abi, Hex } from 'viem'; + +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { portalAbi } from '@/commons/abis/portal.abi'; +import { PortalHashUtils } from '@/commons/utils/portal-hash.utils'; +import { ErrorCode, RoutesCliError } from '@/shared/errors'; +import { KeyHandle } from '@/shared/security'; +import { ChainConfig, ChainType, Intent, UniversalAddress } from '@/shared/types'; +import { logger } from '@/utils/logger'; + +import { BasePublisher, IntentStatus, PublishResult, ValidationResult } from '../base.publisher'; +import { ChainRegistryService } from '../chain-registry.service'; +import { ChainsService } from '../chains.service'; + +import { DefaultTvmClientFactory, TvmClientFactory } from './tvm-client-factory'; + +@Injectable() +export class TvmPublisher extends BasePublisher { + private readonly factory: TvmClientFactory; + + constructor( + rpcUrl: string, + registry: ChainRegistryService, + private readonly chains: ChainsService, + factory: TvmClientFactory = new DefaultTvmClientFactory() + ) { + super(rpcUrl, registry); + this.factory = factory; + } + + override async publish( + source: bigint, + destination: bigint, + reward: Intent['reward'], + encodedRoute: string, + keyHandle: KeyHandle, + _portalAddress?: UniversalAddress + ): Promise { + this.runPreflightChecks(source); + return keyHandle.useAsync(async rawKey => { + const tronWeb: TronWeb = this.factory.createClient(this.rpcUrl); + tronWeb.setPrivateKey(rawKey); + const senderAddress = tronWeb.address.fromPrivateKey(rawKey); + + return this.runSafely(async () => { + const chainConfig = this.chains.findChainById(source); + const portalAddrUniversal = _portalAddress ?? chainConfig?.portalAddress; + if (!portalAddrUniversal) { + throw new Error(`No Portal address configured for chain ${source}`); + } + const portalAddress = AddressNormalizer.denormalize(portalAddrUniversal, ChainType.TVM); + + const destChainConfig = this.chains.findChainById(BigInt(destination)); + if (!destChainConfig) { + throw new Error(`Unknown destination chain: ${destination}`); + } + + for (const rewardToken of reward.tokens) { + const tokenAddress = AddressNormalizer.denormalizeToTvm(rewardToken.token); + const tokenContract = tronWeb.contract(erc20Abi, tokenAddress); + logger.spinner(`Approving token ${tokenAddress}...`); + const approvalTxId = await tokenContract + .approve(portalAddress, rewardToken.amount) + .send({ from: senderAddress }); + logger.updateSpinner('Waiting for approval confirmation...'); + const approved = await this.waitForTransaction(tronWeb, approvalTxId); + if (!approved) { + throw new RoutesCliError( + ErrorCode.TRANSACTION_FAILED, + `Approval failed for ${tokenAddress}` + ); + } + logger.succeed(`Token approved: ${tokenAddress}`); + } + + const portalContract = tronWeb.contract(portalAbi, portalAddress); + + const tvmReward: Parameters[0][2] = [ + reward.deadline, + AddressNormalizer.denormalize(reward.creator, ChainType.TVM), + AddressNormalizer.denormalize(reward.prover, ChainType.TVM), + reward.nativeAmount, + reward.tokens.map( + t => [AddressNormalizer.denormalize(t.token, ChainType.TVM), t.amount] as const + ), + ]; + + logger.spinner('Publishing intent to Portal contract...'); + const tx = await portalContract + .publishAndFund(destination, encodedRoute, tvmReward, false) + .send({ + from: senderAddress, + callValue: Number(reward.nativeAmount), + }); + + logger.updateSpinner('Waiting for transaction confirmation...'); + + const { intentHash } = PortalHashUtils.getIntentHashFromReward( + destination, + source, + encodedRoute as Hex, + reward + ); + + if (tx) { + logger.succeed('Transaction confirmed'); + return { + success: true, + transactionHash: tx, + intentHash, + }; + } else { + logger.fail('Transaction failed'); + return { + success: false, + error: 'Transaction failed', + }; + } + }); + }); + } + + override async getBalance(address: string, _chainId?: bigint): Promise { + try { + const tronWeb = this.factory.createClient(this.rpcUrl); + const balance = await tronWeb.trx.getBalance(address); + return BigInt(balance); + } catch { + return 0n; + } + } + + override async validate( + reward: Intent['reward'], + senderAddress: string, + _chainId: bigint + ): Promise { + const errors: string[] = []; + + if (reward.tokens.length === 0) { + errors.push('TVM requires at least one reward token'); + } + + if (reward.nativeAmount > 0n) { + const balance = await this.getBalance(senderAddress); + if (balance < reward.nativeAmount) { + errors.push( + `Insufficient TRX balance. Required: ${reward.nativeAmount}, Available: ${balance}` + ); + } + } + + const tronWeb = this.factory.createClient(this.rpcUrl); + for (const token of reward.tokens) { + try { + const tokenAddr = AddressNormalizer.denormalizeToTvm(token.token); + const contract = tronWeb.contract(erc20Abi, tokenAddr); + const balance: bigint = await contract.balanceOf(senderAddress).call(); + if (BigInt(balance) < token.amount) { + errors.push( + `Insufficient token balance for ${tokenAddr}. Required: ${token.amount}, Available: ${balance}` + ); + } + } catch { + // Skip token balance check if contract read fails + } + } + + return { valid: errors.length === 0, errors }; + } + + override getStatus( + _intentHash: string, + _chain: ChainConfig, + _portalAddress?: UniversalAddress + ): Promise { + return Promise.reject(new Error('getStatus not yet implemented for TVM')); + } + + private async waitForTransaction(tronWeb: TronWeb, txId: string): Promise { + for (let i = 0; i < 20; i++) { + const txInfo = await tronWeb.trx.getTransactionInfo(txId); + if (txInfo && txInfo.blockNumber && txInfo.receipt?.result === 'SUCCESS') { + return true; + } + + if (txInfo?.receipt?.result === 'FAILED') { + throw new Error( + `Transaction failed: ${txInfo.receipt.result || 'Unknown error'}. txId: ${txId}. Received: ${JSON.stringify(txInfo.receipt)}` + ); + } + + await new Promise(resolve => setTimeout(resolve, 4_000)); + } + return false; + } +} diff --git a/src/blockchain/utils/address-normalizer.ts b/src/blockchain/utils/address-normalizer.ts new file mode 100644 index 0000000..b492684 --- /dev/null +++ b/src/blockchain/utils/address-normalizer.ts @@ -0,0 +1,130 @@ +import { PublicKey } from '@solana/web3.js'; +import { TronWeb } from 'tronweb'; +import { getAddress, isAddress as isViemAddress } from 'viem'; + +import { getErrorMessage } from '@/commons/utils/error-handler'; +import { RoutesCliError } from '@/shared/errors'; +import { BlockchainAddress, ChainType, EvmAddress, SvmAddress, TronAddress } from '@/shared/types'; +import { padTo32Bytes, UniversalAddress, unpadFrom32Bytes } from '@/shared/types'; + +export class AddressNormalizer { + static normalize(address: BlockchainAddress, chainType: ChainType): UniversalAddress { + switch (chainType) { + case ChainType.EVM: + return AddressNormalizer.normalizeEvm(address as EvmAddress); + case ChainType.TVM: + return AddressNormalizer.normalizeTvm(address as TronAddress); + case ChainType.SVM: + return AddressNormalizer.normalizeSvm(address as SvmAddress); + default: + throw RoutesCliError.unsupportedChain(chainType as string); + } + } + + static denormalize< + chainType extends ChainType, + Addr extends chainType extends ChainType.TVM + ? TronAddress + : chainType extends ChainType.EVM + ? EvmAddress + : chainType extends ChainType.SVM + ? SvmAddress + : never, + >(address: UniversalAddress, chainType: chainType): Addr { + switch (chainType) { + case ChainType.EVM: + return AddressNormalizer.denormalizeToEvm(address) as Addr; + case ChainType.TVM: + return AddressNormalizer.denormalizeToTvm(address) as Addr; + case ChainType.SVM: + return AddressNormalizer.denormalizeToSvm(address) as Addr; + default: + throw RoutesCliError.unsupportedChain(chainType as string); + } + } + + static denormalizeToEvm(address: UniversalAddress): EvmAddress { + const unpadded = unpadFrom32Bytes(address); + const cleanHex = unpadded.substring(2); + const evmHex = cleanHex.length > 40 ? cleanHex.substring(cleanHex.length - 40) : cleanHex; + const evmAddress = '0x' + evmHex; + if (!isViemAddress(evmAddress)) { + throw new Error(`Invalid EVM address after denormalization: ${evmAddress}`); + } + return getAddress(evmAddress); + } + + static denormalizeToTvm(address: UniversalAddress): TronAddress { + try { + const unpadded = unpadFrom32Bytes(address); + const hexAddress = unpadded.startsWith('0x41') + ? unpadded.substring(2) + : '41' + unpadded.substring(2); + const base58Address = TronWeb.address.fromHex(hexAddress); + if (!TronWeb.isAddress(base58Address)) { + throw new Error(`Invalid Tron address after denormalization: ${base58Address}`); + } + return base58Address as TronAddress; + } catch (error) { + throw new Error(`Failed to denormalize to TVM address: ${getErrorMessage(error)}`); + } + } + + static denormalizeToSvm(address: UniversalAddress): SvmAddress { + try { + const hex = address.startsWith('0x') ? address.slice(2) : address; + const bytes = Buffer.from(hex, 'hex'); + if (bytes.length !== 32) { + throw new Error(`Expected 32 bytes, got ${bytes.length}`); + } + const publicKey = new PublicKey(bytes); + return publicKey.toBase58() as SvmAddress; + } catch (error) { + throw new Error(`Failed to denormalize to SVM address: ${getErrorMessage(error)}`); + } + } + + static normalizeEvm(address: EvmAddress): UniversalAddress { + if (!isViemAddress(address)) { + throw RoutesCliError.invalidAddress(address, ChainType.EVM); + } + const checksummed = getAddress(address); + return padTo32Bytes(checksummed) as UniversalAddress; + } + + static normalizeTvm(address: TronAddress): UniversalAddress { + try { + let hexAddress: string; + if (address.startsWith('0x')) { + const hexTronAddr = address.startsWith('0x41') ? address : '0x41' + address.substring(2); + const base58 = TronWeb.address.fromHex(hexTronAddr.substring(2)); + if (!TronWeb.isAddress(base58)) { + throw new Error(`Invalid Tron hex address: ${address}`); + } + hexAddress = hexTronAddr.toLowerCase(); + } else { + if (!TronWeb.isAddress(address)) { + throw new Error(`Invalid Tron base58 address: ${address}`); + } + const tronHex = TronWeb.address.toHex(address); + hexAddress = '0x' + tronHex.toLowerCase(); + } + return padTo32Bytes(hexAddress) as UniversalAddress; + } catch (error) { + if (error instanceof RoutesCliError) throw error; + throw RoutesCliError.invalidAddress(address, ChainType.TVM); + } + } + + static normalizeSvm(address: SvmAddress | PublicKey): UniversalAddress { + try { + const publicKey = address instanceof PublicKey ? address : new PublicKey(address); + const bytes = publicKey.toBytes(); + const hex = '0x' + Buffer.from(bytes).toString('hex'); + return hex as UniversalAddress; + } catch { + const addrStr = address instanceof PublicKey ? address.toBase58() : String(address); + throw RoutesCliError.invalidAddress(addrStr, ChainType.SVM); + } + } +} diff --git a/src/blockchain/utils/portal-encoder.ts b/src/blockchain/utils/portal-encoder.ts new file mode 100644 index 0000000..f8858dc --- /dev/null +++ b/src/blockchain/utils/portal-encoder.ts @@ -0,0 +1,192 @@ +import { BN, web3 } from '@coral-xyz/anchor'; +import { decodeAbiParameters, encodeAbiParameters, Hex } from 'viem'; + +import { EVMRewardAbiItem, EVMRouteAbiItem } from '@/commons/abis/portal.abi'; +import { RewardInstruction, RouteInstruction } from '@/commons/types/portal-idl-coder.type'; +import { bufferToBytes } from '@/commons/utils/converter'; +import { toSvmRewardForCoder, toSvmRouteForCoder } from '@/commons/utils/instruments'; +import { portalBorshCoder } from '@/commons/utils/portal-borsh-coder'; +import { ChainType, Intent, UniversalAddress } from '@/shared/types'; + +import { AddressNormalizer } from './address-normalizer'; + +export function isRoute(data: Intent['route'] | Intent['reward']): data is Intent['route'] { + return 'salt' in data && 'portal' in data && 'calls' in data; +} + +function encodeEvm(data: Intent['route'] | Intent['reward']): Hex { + if (isRoute(data)) { + return encodeAbiParameters( + [EVMRouteAbiItem], + [ + { + salt: data.salt, + deadline: data.deadline, + nativeAmount: data.nativeAmount, + portal: AddressNormalizer.denormalizeToEvm(data.portal), + tokens: data.tokens.map(t => ({ + token: AddressNormalizer.denormalizeToEvm(t.token), + amount: t.amount, + })), + calls: data.calls.map(c => ({ + target: AddressNormalizer.denormalizeToEvm(c.target), + data: c.data, + value: c.value, + })), + }, + ] + ); + } else { + return encodeAbiParameters( + [EVMRewardAbiItem], + [ + { + deadline: data.deadline, + creator: AddressNormalizer.denormalizeToEvm(data.creator), + prover: AddressNormalizer.denormalizeToEvm(data.prover), + nativeAmount: data.nativeAmount, + tokens: data.tokens.map(t => ({ + token: AddressNormalizer.denormalizeToEvm(t.token), + amount: t.amount, + })), + }, + ] + ); + } +} + +function encodeSvm(data: Intent['route'] | Intent['reward']): Hex { + if (isRoute(data)) { + return bufferToBytes( + portalBorshCoder.types.encode('Route', toSvmRouteForCoder(data)) + ); + } else { + return bufferToBytes( + portalBorshCoder.types.encode('Reward', toSvmRewardForCoder(data)) + ); + } +} + +function decodeEvmRoute(hex: Hex): Intent['route'] { + const [decoded] = decodeAbiParameters([EVMRouteAbiItem], hex); + const d = decoded as { + salt: `0x${string}`; + deadline: bigint; + portal: `0x${string}`; + nativeAmount: bigint; + tokens: Array<{ token: `0x${string}`; amount: bigint }>; + calls: Array<{ target: `0x${string}`; data: `0x${string}`; value: bigint }>; + }; + return { + salt: d.salt, + deadline: d.deadline, + portal: AddressNormalizer.normalizeEvm(d.portal) as UniversalAddress, + nativeAmount: d.nativeAmount, + tokens: d.tokens.map(t => ({ + token: AddressNormalizer.normalizeEvm(t.token) as UniversalAddress, + amount: t.amount, + })), + calls: d.calls.map(c => ({ + target: AddressNormalizer.normalizeEvm(c.target) as UniversalAddress, + data: c.data, + value: c.value, + })), + }; +} + +function decodeEvmReward(hex: Hex): Intent['reward'] { + const [decoded] = decodeAbiParameters([EVMRewardAbiItem], hex); + const d = decoded as { + deadline: bigint; + creator: `0x${string}`; + prover: `0x${string}`; + nativeAmount: bigint; + tokens: Array<{ token: `0x${string}`; amount: bigint }>; + }; + return { + deadline: d.deadline, + creator: AddressNormalizer.normalizeEvm(d.creator) as UniversalAddress, + prover: AddressNormalizer.normalizeEvm(d.prover) as UniversalAddress, + nativeAmount: d.nativeAmount, + tokens: d.tokens.map(t => ({ + token: AddressNormalizer.normalizeEvm(t.token) as UniversalAddress, + amount: t.amount, + })), + }; +} + +function decodeSvmRoute(hex: Hex): Intent['route'] { + const bytes = Buffer.from(hex.slice(2), 'hex'); + const decoded = portalBorshCoder.types.decode('Route', bytes); + const salt = ('0x' + Buffer.from(decoded.salt[0] as number[]).toString('hex')) as Hex; + const portalBytes = Buffer.from(decoded.portal[0] as number[]); + const portalPk = new web3.PublicKey(portalBytes); + return { + salt, + deadline: BigInt(decoded.deadline.toString()), + portal: AddressNormalizer.normalizeSvm(portalPk) as UniversalAddress, + nativeAmount: BigInt(decoded.native_amount.toString()), + tokens: (decoded.tokens as Array<{ token: web3.PublicKey; amount: BN }>).map(t => ({ + token: AddressNormalizer.normalizeSvm(t.token) as UniversalAddress, + amount: BigInt(t.amount.toString()), + })), + calls: (decoded.calls as Array<{ target: { 0: number[] }; data: Buffer }>).map(c => ({ + target: AddressNormalizer.normalizeSvm( + new web3.PublicKey(Buffer.from(c.target[0])) + ) as UniversalAddress, + data: ('0x' + Buffer.from(c.data).toString('hex')) as Hex, + value: 0n, + })), + }; +} + +function decodeSvmReward(hex: Hex): Intent['reward'] { + const bytes = Buffer.from(hex.slice(2), 'hex'); + const decoded = portalBorshCoder.types.decode('Reward', bytes); + const creatorPk = decoded.creator as web3.PublicKey; + const proverPk = decoded.prover as web3.PublicKey; + return { + deadline: BigInt(decoded.deadline.toString()), + creator: AddressNormalizer.normalizeSvm(creatorPk) as UniversalAddress, + prover: AddressNormalizer.normalizeSvm(proverPk) as UniversalAddress, + nativeAmount: BigInt(decoded.native_amount.toString()), + tokens: (decoded.tokens as Array<{ token: web3.PublicKey; amount: BN }>).map(t => ({ + token: AddressNormalizer.normalizeSvm(t.token) as UniversalAddress, + amount: BigInt(t.amount.toString()), + })), + }; +} + +export class PortalEncoder { + static isRoute(data: Intent['route'] | Intent['reward']): data is Intent['route'] { + return isRoute(data); + } + + static encode(data: Intent['route'] | Intent['reward'], chainType: ChainType): Hex { + switch (chainType) { + case ChainType.EVM: + case ChainType.TVM: + return encodeEvm(data); + case ChainType.SVM: + return encodeSvm(data); + default: + throw new Error(`Unsupported chain type: ${chainType}`); + } + } + + static decode(hex: Hex, chainType: ChainType, kind: K): Intent[K] { + switch (chainType) { + case ChainType.EVM: + case ChainType.TVM: + return kind === 'route' + ? (decodeEvmRoute(hex) as Intent[K]) + : (decodeEvmReward(hex) as Intent[K]); + case ChainType.SVM: + return kind === 'route' + ? (decodeSvmRoute(hex) as Intent[K]) + : (decodeSvmReward(hex) as Intent[K]); + default: + throw new Error(`Unsupported chain type: ${chainType}`); + } + } +} diff --git a/src/blockchain/validation/index.ts b/src/blockchain/validation/index.ts new file mode 100644 index 0000000..47d7270 --- /dev/null +++ b/src/blockchain/validation/index.ts @@ -0,0 +1,10 @@ +export { + ChainIdSchema, + EvmAddressSchema, + EvmPrivateKeySchema, + SvmAddressSchema, + TokenAmountSchema, + TvmAddressSchema, + TvmPrivateKeySchema, + UniversalAddressSchema, +} from './schemas'; diff --git a/src/blockchain/validation/schemas.ts b/src/blockchain/validation/schemas.ts new file mode 100644 index 0000000..037c14a --- /dev/null +++ b/src/blockchain/validation/schemas.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +export const EvmAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/, { + message: 'EVM address must be 0x followed by 40 hex characters', +}); + +export const UniversalAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/, { + message: 'Universal address must be 0x followed by 64 hex characters', +}); + +// Accepts base58 (T...) or Tron hex (0x41... or 41...) format +export const TvmAddressSchema = z.union([ + z.string().regex(/^T[A-Za-z0-9]{33}$/, { + message: 'Tron base58 address must start with T and be 34 characters', + }), + z.string().regex(/^(0x)?41[a-fA-F0-9]{40}$/, { + message: 'Tron hex address must start with 41 or 0x41 followed by 40 hex characters', + }), +]); + +// Solana base58 public key: 32–44 base58 characters +export const SvmAddressSchema = z + .string() + .min(32, { message: 'Solana address must be at least 32 characters' }) + .max(44, { message: 'Solana address must be at most 44 characters' }) + .regex(/^[1-9A-HJ-NP-Za-km-z]+$/, { + message: 'Solana address must be base58 encoded', + }); + +export const EvmPrivateKeySchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/, { + message: 'EVM private key must be 0x followed by 64 hex characters', +}); + +export const TvmPrivateKeySchema = z.string().regex(/^[a-fA-F0-9]{64}$/, { + message: 'TVM private key must be 64 hex characters (no 0x prefix)', +}); + +export const TokenAmountSchema = z + .string() + .regex(/^\d+(\.\d+)?$/, { message: 'Amount must be a positive number (e.g. "10" or "0.5")' }) + .refine(v => parseFloat(v) > 0, { message: 'Amount must be greater than zero' }); + +export const ChainIdSchema = z.bigint().positive({ message: 'Chain ID must be a positive bigint' }); diff --git a/src/cli/cli.module.ts b/src/cli/cli.module.ts new file mode 100644 index 0000000..b1092d7 --- /dev/null +++ b/src/cli/cli.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; + +import { IntentModule } from '@/intent/intent.module'; +import { QuoteModule } from '@/quote/quote.module'; +import { StatusModule } from '@/status/status.module'; + +import { ChainsCommand } from './commands/chains.command'; +import { ConfigCommand } from './commands/config.command'; +import { PublishCommand } from './commands/publish.command'; +import { StatusCommand } from './commands/status.command'; +import { TokensCommand } from './commands/tokens.command'; +import { DisplayModule } from './services/display.module'; +import { PromptService } from './services/prompt.service'; + +@Module({ + imports: [QuoteModule, IntentModule, StatusModule, DisplayModule], + providers: [ + PromptService, + PublishCommand, + StatusCommand, + ConfigCommand, + ChainsCommand, + TokensCommand, + ], +}) +export class CliModule {} diff --git a/src/cli/commands/chains.command.ts b/src/cli/commands/chains.command.ts new file mode 100644 index 0000000..c5813f0 --- /dev/null +++ b/src/cli/commands/chains.command.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { ChainsService } from '@/blockchain/chains.service'; + +import { DisplayService } from '../services/display.service'; + +@Injectable() +@Command({ name: 'chains', description: 'List supported chains' }) +export class ChainsCommand extends CommandRunner { + constructor( + private readonly chains: ChainsService, + private readonly display: DisplayService + ) { + super(); + } + + run(): Promise { + this.display.displayChains(this.chains.listChains()); + return Promise.resolve(); + } +} diff --git a/src/cli/commands/config.command.ts b/src/cli/commands/config.command.ts new file mode 100644 index 0000000..d3f5c2b --- /dev/null +++ b/src/cli/commands/config.command.ts @@ -0,0 +1,416 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { Injectable } from '@nestjs/common'; + +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { ConfigService } from '@/config/config.service'; +import { ChainType } from '@/shared/types'; + +import { PromptService } from '../services/prompt.service'; + +interface ConfigSettings { + defaultSourceChain?: string; + defaultDestinationChain?: string; + defaultPrivateKeys?: { + [ChainType.EVM]?: string; + [ChainType.TVM]?: string; + [ChainType.SVM]?: string; + }; + rpcUrls?: Record; + profiles?: Record; + currentProfile?: string; +} + +interface ConfigOptions { + interactive?: boolean; + profile?: string; + force?: boolean; +} + +const CONFIG_DIR = path.join(os.homedir(), '.eco-routes'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); +const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles'); + +@Injectable() +@Command({ + name: 'config', + description: 'Manage CLI configuration settings', + arguments: '[subcommand] [key] [value]', +}) +export class ConfigCommand extends CommandRunner { + constructor( + private readonly configService: ConfigService, + private readonly prompt: PromptService + ) { + super(); + } + + async run(inputs: string[], options: ConfigOptions): Promise { + const [subcommand, key, value] = inputs; + + switch (subcommand) { + case 'list': + this.runList(options.profile); + break; + case 'set': + await this.runSet(key, value, options); + break; + case 'get': + this.runGet(key, options.profile); + break; + case 'unset': + this.runUnset(key, options.profile); + break; + case 'reset': + await this.runReset(options); + break; + case 'profile': + await this.runProfile(key, value, options); + break; + default: + console.log('Usage: config [args]'); + console.log(' list Show current configuration'); + console.log(' set Set a configuration value'); + console.log(' set --interactive Interactive guided setup'); + console.log(' get Get a configuration value'); + console.log(' unset Remove a configuration key'); + console.log(' reset Reset configuration to defaults'); + console.log(' profile [name]'); + } + } + + private runList(profileName?: string): void { + const config = this.loadConfig(); + if (profileName) { + if (!config.profiles?.[profileName]) { + console.error(`Profile '${profileName}' not found`); + process.exit(1); + } + console.log(`📋 Profile: ${profileName}`); + this.displayConfig(config.profiles[profileName]); + } else { + console.log('📋 Current Configuration'); + if (config.currentProfile) console.log(`Active Profile: ${config.currentProfile}\n`); + this.displayConfig(config); + if (config.profiles && Object.keys(config.profiles).length > 0) { + console.log('\nAvailable Profiles:'); + for (const name of Object.keys(config.profiles)) { + console.log(` • ${name}${name === config.currentProfile ? ' (active)' : ''}`); + } + } + } + } + + private async runSet( + key: string | undefined, + value: string | undefined, + options: ConfigOptions + ): Promise { + if (options.interactive || (!key && !value)) { + await this.setConfigInteractive(options.profile); + } else if (key && value !== undefined) { + this.setConfigValue(key, value, options.profile); + } else { + console.error('Please provide both key and value, or use --interactive mode'); + process.exit(1); + } + } + + private runGet(key: string | undefined, profileName?: string): void { + if (!key) { + console.error('Key is required'); + process.exit(1); + } + const config = this.loadConfig(); + const target = profileName ? (config.profiles?.[profileName] ?? {}) : config; + const val = this.getNestedValue(target, key); + if (val !== undefined) { + console.log(key.toLowerCase().includes('private') ? '***[HIDDEN]***' : String(val)); + } else { + console.warn(`Configuration key '${key}' not found`); + process.exit(1); + } + } + + private runUnset(key: string | undefined, profileName?: string): void { + if (!key) { + console.error('Key is required'); + process.exit(1); + } + this.unsetConfigValue(key, profileName); + console.log(`Configuration key '${key}' removed`); + } + + private async runReset(options: ConfigOptions): Promise { + if (!options.force) { + const target = options.profile ? `profile '${options.profile}'` : 'entire configuration'; + const ok = await this.prompt.confirm(`Reset ${target}?`); + if (!ok) { + console.log('Reset cancelled'); + return; + } + } + this.resetConfig(options.profile); + console.log(options.profile ? `Profile '${options.profile}' reset` : 'Configuration reset'); + } + + private async runProfile( + op: string | undefined, + name: string | undefined, + options: ConfigOptions + ): Promise { + switch (op) { + case 'create': + if (!name) { + console.error('Profile name is required'); + process.exit(1); + } + this.createProfile(name); + console.log(`Profile '${name}' created`); + break; + case 'switch': + if (!name) { + console.error('Profile name is required'); + process.exit(1); + } + this.switchProfile(name); + console.log(`Switched to profile '${name}'`); + break; + case 'delete': + if (!name) { + console.error('Profile name is required'); + process.exit(1); + } + if (!options.force) { + const ok = await this.prompt.confirm(`Delete profile '${name}'?`); + if (!ok) { + console.log('Cancelled'); + return; + } + } + this.deleteProfile(name); + console.log(`Profile '${name}' deleted`); + break; + case 'list': { + const config = this.loadConfig(); + if (!config.profiles || Object.keys(config.profiles).length === 0) { + console.log('No profiles found'); + return; + } + console.log('📋 Available Profiles:'); + for (const n of Object.keys(config.profiles)) { + console.log(` • ${n}${n === config.currentProfile ? ' (active)' : ''}`); + } + break; + } + default: + console.log('Usage: config profile [name]'); + } + } + + private async setConfigInteractive(profileName?: string): Promise { + const config = this.loadConfig(); + const target: ConfigSettings = profileName ? (config.profiles?.[profileName] ?? {}) : config; + const envConfig = this.configService; + + const { inquirer } = await import('inquirer').then(m => ({ inquirer: m.default })); + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'defaultSourceChain', + message: 'Default source chain:', + default: target.defaultSourceChain, + }, + { + type: 'input', + name: 'defaultDestinationChain', + message: 'Default destination chain:', + default: target.defaultDestinationChain, + }, + { type: 'password', name: 'evmKey', message: 'EVM private key (optional):', mask: '*' }, + { type: 'password', name: 'tvmKey', message: 'TVM private key (optional):', mask: '*' }, + { type: 'password', name: 'svmKey', message: 'SVM private key (optional):', mask: '*' }, + ]); + + void envConfig; + + if (answers.defaultSourceChain) + target.defaultSourceChain = answers.defaultSourceChain as string; + if (answers.defaultDestinationChain) + target.defaultDestinationChain = answers.defaultDestinationChain as string; + if (!target.defaultPrivateKeys) target.defaultPrivateKeys = {}; + if (answers.evmKey) target.defaultPrivateKeys[ChainType.EVM] = answers.evmKey as string; + if (answers.tvmKey) target.defaultPrivateKeys[ChainType.TVM] = answers.tvmKey as string; + if (answers.svmKey) target.defaultPrivateKeys[ChainType.SVM] = answers.svmKey as string; + + if (profileName) { + if (!config.profiles) config.profiles = {}; + config.profiles[profileName] = target; + } else { + Object.assign(config, target); + } + + this.saveConfig(config); + console.log('✅ Configuration updated'); + } + + private loadConfig(): ConfigSettings { + if (!fs.existsSync(CONFIG_FILE)) return {}; + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) as ConfigSettings; + } + + private saveConfig(config: ConfigSettings): void { + this.ensureConfigDir(); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } + + private ensureConfigDir(): void { + if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true }); + if (!fs.existsSync(PROFILES_DIR)) fs.mkdirSync(PROFILES_DIR, { recursive: true }); + } + + private displayConfig(config: ConfigSettings): void { + if (config.defaultSourceChain) + console.log(` Default Source Chain: ${config.defaultSourceChain}`); + if (config.defaultDestinationChain) + console.log(` Default Destination Chain: ${config.defaultDestinationChain}`); + if (config.rpcUrls) { + for (const [chain, url] of Object.entries(config.rpcUrls)) { + console.log(` RPC URL (${chain}): ${url}`); + } + } + if (config.defaultPrivateKeys) { + for (const [chainType, key] of Object.entries(config.defaultPrivateKeys)) { + if (key) console.log(` Private Key (${chainType}): ***[SET]***`); + } + } + if ( + !config.defaultSourceChain && + !config.defaultDestinationChain && + !config.rpcUrls && + !config.defaultPrivateKeys + ) { + console.log(' No configuration set'); + } + } + + private setConfigValue(key: string, value: string, profileName?: string): void { + const config = this.loadConfig(); + const target: ConfigSettings = profileName ? (config.profiles?.[profileName] ?? {}) : config; + this.setNestedValue(target, key, value); + if (profileName) { + if (!config.profiles) config.profiles = {}; + config.profiles[profileName] = target; + } else { + Object.assign(config, target); + } + this.saveConfig(config); + console.log(`✅ '${key}' set to '${value}'`); + } + + private unsetConfigValue(key: string, profileName?: string): void { + const config = this.loadConfig(); + const target: ConfigSettings = profileName ? (config.profiles?.[profileName] ?? {}) : config; + this.deleteNestedValue(target, key); + if (profileName) { + if (!config.profiles) config.profiles = {}; + config.profiles[profileName] = target; + } else { + Object.assign(config, target); + } + this.saveConfig(config); + } + + private createProfile(name: string): void { + const config = this.loadConfig(); + if (!config.profiles) config.profiles = {}; + if (config.profiles[name]) throw new Error(`Profile '${name}' already exists`); + config.profiles[name] = {}; + this.saveConfig(config); + } + + private switchProfile(name: string): void { + const config = this.loadConfig(); + if (!config.profiles?.[name]) throw new Error(`Profile '${name}' does not exist`); + config.currentProfile = name; + this.saveConfig(config); + } + + private deleteProfile(name: string): void { + const config = this.loadConfig(); + if (!config.profiles?.[name]) throw new Error(`Profile '${name}' does not exist`); + delete config.profiles[name]; + if (config.currentProfile === name) delete config.currentProfile; + this.saveConfig(config); + } + + private resetConfig(profileName?: string): void { + if (profileName) { + const config = this.loadConfig(); + if (config.profiles?.[profileName]) { + config.profiles[profileName] = {}; + this.saveConfig(config); + } + } else if (fs.existsSync(CONFIG_FILE)) { + fs.unlinkSync(CONFIG_FILE); + } + } + + private getNestedValue(obj: ConfigSettings | Record, keyPath: string): unknown { + return keyPath.split('.').reduce((cur: unknown, k) => { + if (cur && typeof cur === 'object' && k in (cur as Record)) { + return (cur as Record)[k]; + } + return undefined; + }, obj); + } + + private setNestedValue( + obj: ConfigSettings | Record, + keyPath: string, + value: unknown + ): void { + const keys = keyPath.split('.'); + const last = keys.pop()!; + const target = keys.reduce( + (cur: Record, k) => { + if (!cur[k] || typeof cur[k] !== 'object') cur[k] = {}; + return cur[k] as Record; + }, + obj as Record + ); + target[last] = value; + } + + private deleteNestedValue(obj: ConfigSettings | Record, keyPath: string): void { + const keys = keyPath.split('.'); + const last = keys.pop()!; + const target = keys.reduce((cur: unknown, k) => { + if (cur && typeof cur === 'object' && k in (cur as Record)) { + return (cur as Record)[k]; + } + return undefined; + }, obj as unknown); + if (target && typeof target === 'object' && last in (target as Record)) { + delete (target as Record)[last]; + } + } + + @Option({ flags: '-i, --interactive', description: 'Interactive mode' }) + parseInteractive(): boolean { + return true; + } + + @Option({ flags: '--profile ', description: 'Target profile' }) + parseProfile(val: string): string { + return val; + } + + @Option({ flags: '--force', description: 'Skip confirmation' }) + parseForce(): boolean { + return true; + } +} diff --git a/src/cli/commands/publish.command.ts b/src/cli/commands/publish.command.ts new file mode 100644 index 0000000..84fa39b --- /dev/null +++ b/src/cli/commands/publish.command.ts @@ -0,0 +1,387 @@ +import { Injectable } from '@nestjs/common'; + +import { Keypair } from '@solana/web3.js'; +import { Command, CommandRunner, Option } from 'nest-commander'; +import { TronWeb } from 'tronweb'; +import { Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { AddressNormalizerService } from '@/blockchain/address-normalizer.service'; +import { ChainsService } from '@/blockchain/chains.service'; +import { PublisherFactory } from '@/blockchain/publisher-factory.service'; +import { ConfigService } from '@/config/config.service'; +import { TOKEN_CONFIGS } from '@/config/tokens.config'; +import { IntentBuilder } from '@/intent/intent-builder.service'; +import { IntentStorage } from '@/intent/intent-storage.service'; +import { QuoteResult, QuoteService } from '@/quote/quote.service'; +import { KeyHandle } from '@/shared/security'; +import { BlockchainAddress, ChainType, Intent, UniversalAddress } from '@/shared/types'; +import { IntentStatus, StatusService } from '@/status/status.service'; + +import { DisplayService } from '../services/display.service'; +import { PromptService } from '../services/prompt.service'; + +function deriveAddress(key: string, chainType: ChainType): string { + switch (chainType) { + case ChainType.EVM: + return privateKeyToAccount(key as Hex).address; + case ChainType.TVM: + return TronWeb.address.fromPrivateKey(key) as string; + case ChainType.SVM: { + let keypair: Keypair; + if (key.startsWith('[') && key.endsWith(']')) { + keypair = Keypair.fromSecretKey(new Uint8Array(JSON.parse(key) as number[])); + } else if (key.includes(',')) { + keypair = Keypair.fromSecretKey( + new Uint8Array(key.split(',').map(b => parseInt(b.trim()))) + ); + } else { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const bs58 = require('bs58') as { decode: (s: string) => Uint8Array }; + keypair = Keypair.fromSecretKey(bs58.decode(key)); + } + return keypair.publicKey.toBase58(); + } + } +} + +interface PublishOptions { + source?: string; + destination?: string; + privateKey?: string; + privateKeyTvm?: string; + privateKeySvm?: string; + rpc?: string; + recipient?: string; + portalAddress?: string; + proverAddress?: string; + dryRun?: boolean; + watch?: boolean; +} + +function resolveKey(options: PublishOptions, chainType: ChainType): string | undefined { + switch (chainType) { + case ChainType.EVM: + return options.privateKey; + case ChainType.TVM: + return options.privateKeyTvm; + case ChainType.SVM: + return options.privateKeySvm; + } +} + +@Injectable() +@Command({ name: 'publish', description: 'Publish an intent to the blockchain' }) +export class PublishCommand extends CommandRunner { + constructor( + private readonly chains: ChainsService, + private readonly config: ConfigService, + private readonly normalizer: AddressNormalizerService, + private readonly publisherFactory: PublisherFactory, + private readonly quoteService: QuoteService, + private readonly intentBuilder: IntentBuilder, + private readonly intentStorage: IntentStorage, + private readonly prompt: PromptService, + private readonly display: DisplayService, + private readonly statusService: StatusService + ) { + super(); + } + + async run(_params: string[], options: PublishOptions): Promise { + this.display.title('🎨 Interactive Intent Publishing'); + + const allChains = this.chains.listChains(); + const sourceChain = options.source + ? this.chains.resolveChain(options.source) + : await this.prompt.selectChain(allChains, 'Select source chain:'); + + const destChain = options.destination + ? this.chains.resolveChain(options.destination) + : await this.prompt.selectChain( + allChains.filter(c => c.id !== sourceChain.id), + 'Select destination chain:' + ); + + const tokens = Object.values(TOKEN_CONFIGS); + + this.display.section('📏 Route Configuration (Destination Chain)'); + const routeToken = await this.prompt.selectToken(destChain, tokens, 'route'); + + this.display.section('💰 Reward Configuration (Source Chain)'); + const rewardToken = await this.prompt.selectToken(sourceChain, tokens, 'reward'); + const { parsed: rewardAmount } = await this.prompt.inputAmount( + rewardToken.symbol ?? 'tokens', + rewardToken.decimals + ); + + this.display.section('👤 Recipient Configuration'); + const destKey = + resolveKey(options, destChain.type) ?? this.config.getKeyForChainType(destChain.type); + const recipientDefault = destKey ? deriveAddress(destKey, destChain.type) : undefined; + const recipientRaw = + options.recipient ?? + (await this.prompt.inputAddress(destChain, 'recipient', recipientDefault)); + const recipient = this.normalizer.normalize( + recipientRaw as Parameters[0], + destChain.type + ); + + const rawKey = + resolveKey(options, sourceChain.type) ?? + this.config.getKeyForChainType(sourceChain.type) ?? + ''; + const keyHandle = new KeyHandle(rawKey); + + // Derive sender address synchronously, then keep async key handle for publisher + let senderAddress: string; + const publishKeyHandle = new KeyHandle(rawKey); + keyHandle.use(key => { + senderAddress = deriveAddress(key, sourceChain.type); + }); + + // Quote or fallback + let encodedRoute: string; + let sourcePortal: UniversalAddress | undefined; + let proverAddress: UniversalAddress | undefined; + let quote: QuoteResult | undefined; + + try { + this.display.spinner('Getting quote...'); + quote = await this.quoteService.getQuote({ + source: sourceChain.id, + destination: destChain.id, + amount: rewardAmount, + funder: senderAddress!, + recipient: recipientRaw, + routeToken: routeToken.address, + rewardToken: rewardToken.address, + }); + this.display.succeed('Quote received'); + this.display.displayQuote(quote, rewardToken, rewardAmount, routeToken); + encodedRoute = quote.encodedRoute; + sourcePortal = this.normalizer.normalize( + quote.sourcePortal as Parameters[0], + sourceChain.type + ); + proverAddress = this.normalizer.normalize( + quote.prover as Parameters[0], + sourceChain.type + ); + } catch (error) { + console.error(error); + this.display.warn('Quote service unavailable — using manual configuration'); + + const { parsed: routeAmount } = await this.prompt.inputAmount( + routeToken.symbol ?? 'tokens', + routeToken.decimals + ); + + const destPortal = destChain.portalAddress!; + const routeTokenUniversal = this.normalizer.normalize( + routeToken.address as Parameters[0], + destChain.type + ); + + const { encodedRoute: manualEncodedRoute } = this.intentBuilder.buildManualRoute({ + destChain, + recipient, + routeToken: routeTokenUniversal, + routeAmount, + portal: destPortal, + }); + encodedRoute = manualEncodedRoute; + } + + // Source portal: CLI arg → interactive prompt (quote already set above if available) + if (!sourcePortal && options.portalAddress) { + sourcePortal = this.normalizer.normalize( + options.portalAddress as Parameters[0], + sourceChain.type + ); + } + if (!sourcePortal) { + const raw = await this.prompt.inputManualPortal(sourceChain); + sourcePortal = this.normalizer.normalize( + raw as Parameters[0], + sourceChain.type + ); + } + + // Prover address: CLI arg → chain config → interactive prompt (quote already set above if available) + if (!proverAddress && options.proverAddress) { + proverAddress = this.normalizer.normalize( + options.proverAddress as Parameters[0], + sourceChain.type + ); + } + if (!proverAddress && sourceChain.proverAddress) { + proverAddress = sourceChain.proverAddress; + } + if (!proverAddress) { + const raw = await this.prompt.inputManualProver(sourceChain); + proverAddress = this.normalizer.normalize( + raw as Parameters[0], + sourceChain.type + ); + } + + const rewardTokenUniversal = this.normalizer.normalize( + rewardToken.address as Parameters[0], + sourceChain.type + ); + + const reward = this.intentBuilder.buildReward({ + sourceChain, + creator: this.normalizer.normalize( + senderAddress! as Parameters[0], + sourceChain.type + ), + prover: proverAddress, + rewardToken: rewardTokenUniversal, + rewardAmount, + }); + + // Display summary + confirm + const confirmed = await this.prompt.confirmPublish(); + if (!confirmed) throw new Error('Publication cancelled by user'); + + if (options.dryRun) { + this.display.warning('Dry run — not publishing'); + return; + } + + const publisher = this.publisherFactory.create(sourceChain); + const result = await publisher.publish( + sourceChain.id, + destChain.id, + reward, + encodedRoute, + publishKeyHandle, + sourcePortal + ); + + if (!result.success) { + this.display.fail('Publishing failed'); + throw new Error(result.error); + } + + const intent: Intent = { + destination: destChain.id, + sourceChainId: sourceChain.id, + route: {} as Intent['route'], + reward, + }; + await this.intentStorage.save(intent, result); + this.display.succeed('Intent published!'); + this.display.displayTransactionResult(result); + + const watchEnabled = options.watch === true; + const canWatch = destChain.type === ChainType.EVM; + + if (watchEnabled && result.intentHash) { + if (!canWatch) { + this.display.log(`Fulfillment watching not yet supported for ${destChain.type} chains.`); + } else { + const timeoutMultipler = 3; + const estimatedSec = quote?.estimatedFulfillTimeSec ?? 300; + const timeoutMs = estimatedSec * timeoutMultipler * 1000; + + this.display.spinner(`Watching for fulfillment on ${destChain.name}...`); + + const watchChain = quote?.destinationPortalAddress + ? { + ...destChain, + portalAddress: this.normalizer.normalize( + quote.destinationPortalAddress as BlockchainAddress, + destChain.type + ), + } + : destChain; + + let finalStatus: IntentStatus | null = null; + const outcome = await this.statusService.watch( + result.intentHash, + watchChain, + status => { + finalStatus = status; + }, + { timeoutMs } + ); + + if (outcome === 'fulfilled' && finalStatus) { + this.display.succeed('Intent fulfilled!'); + this.display.displayFulfillmentResult(finalStatus); + } else { + this.display.warn( + `Not fulfilled within ${estimatedSec * timeoutMultipler}s — check manually: ` + + `routes status ${result.intentHash} --chain ${destChain.name}` + ); + } + } + } + + void recipient; // used in reward/route construction + } + + @Option({ flags: '-s, --source ', description: 'Source chain name or ID' }) + parseSource(val: string): string { + return val; + } + + @Option({ flags: '-d, --destination ', description: 'Destination chain name or ID' }) + parseDestination(val: string): string { + return val; + } + + @Option({ flags: '-k, --private-key ', description: 'EVM private key (overrides env)' }) + parsePrivateKey(val: string): string { + return val; + } + + @Option({ flags: '--private-key-tvm ', description: 'TVM private key (overrides env)' }) + parsePrivateKeyTvm(val: string): string { + return val; + } + + @Option({ flags: '--private-key-svm ', description: 'SVM private key (overrides env)' }) + parsePrivateKeySvm(val: string): string { + return val; + } + + @Option({ flags: '-r, --rpc ', description: 'RPC URL override' }) + parseRpc(val: string): string { + return val; + } + + @Option({ flags: '--recipient
', description: 'Recipient address on destination chain' }) + parseRecipient(val: string): string { + return val; + } + + @Option({ + flags: '--portal-address
', + description: 'Portal contract address on the source chain', + }) + parsePortalAddress(val: string): string { + return val; + } + + @Option({ + flags: '--prover-address
', + description: 'Prover contract address on the source chain', + }) + parseProverAddress(val: string): string { + return val; + } + + @Option({ flags: '--dry-run', description: 'Validate without broadcasting' }) + parseDryRun(): boolean { + return true; + } + + @Option({ flags: '-w, --watch', description: 'Watch for fulfillment after publishing' }) + parseWatch(): boolean { + return true; + } +} diff --git a/src/cli/commands/status.command.ts b/src/cli/commands/status.command.ts new file mode 100644 index 0000000..56e039c --- /dev/null +++ b/src/cli/commands/status.command.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; + +import chalk from 'chalk'; +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { ChainsService } from '@/blockchain/chains.service'; +import { IntentStatus, StatusService } from '@/status/status.service'; + +import { DisplayService } from '../services/display.service'; + +interface StatusOptions { + chain?: string; + watch?: boolean; + json?: boolean; + verbose?: boolean; +} + +@Injectable() +@Command({ + name: 'status', + description: 'Check the fulfillment status of an intent', + arguments: '', +}) +export class StatusCommand extends CommandRunner { + constructor( + private readonly chains: ChainsService, + private readonly statusService: StatusService, + private readonly display: DisplayService + ) { + super(); + } + + async run(inputs: string[], options: StatusOptions): Promise { + const intentHash = inputs[0]; + + if (!intentHash || !intentHash.startsWith('0x') || intentHash.length !== 66) { + this.display.error('Intent hash must be a 0x-prefixed 64-character hex string'); + process.exit(1); + } + + if (!options.chain) { + this.display.error('Destination chain is required. Use --chain option.'); + process.exit(1); + } + + const chain = this.chains.resolveChain(options.chain); + + if (!options.json && !options.watch) { + this.display.title('🔍 Checking Intent Status'); + this.display.log(`Intent Hash: ${intentHash}`); + this.display.log(`Chain: ${chain.name} (${chain.id})`); + } + + if (options.watch) { + await this.statusService.watch( + intentHash, + chain, + status => this.displayStatus(status, options), + {} + ); + } else { + const status = await this.statusService.getStatus(intentHash, chain); + this.displayStatus(status, options); + } + } + + private displayStatus(status: IntentStatus, options: StatusOptions): void { + if (options.json) { + console.log(JSON.stringify(status, (_k, v) => (typeof v === 'bigint' ? v.toString() : v), 2)); + return; + } + + const statusText = status.fulfilled ? chalk.green('✅ Fulfilled') : chalk.yellow('⏳ Pending'); + this.display.log(`Status: ${statusText}`); + + if (status.fulfilled) { + if (status.solver) this.display.log(`Solver: ${status.solver}`); + if (status.fulfillmentTxHash) this.display.log(`Tx: ${status.fulfillmentTxHash}`); + if (status.blockNumber) this.display.log(`Block: ${status.blockNumber.toString()}`); + if (status.timestamp) + this.display.log(`Time: ${new Date(status.timestamp * 1000).toLocaleString()}`); + } else { + this.display.log('The intent has not been fulfilled yet.'); + } + } + + @Option({ flags: '-c, --chain ', description: 'Destination chain (name or ID)' }) + parseChain(val: string): string { + return val; + } + + @Option({ flags: '-w, --watch', description: 'Poll every 10 seconds until fulfilled' }) + parseWatch(): boolean { + return true; + } + + @Option({ flags: '--json', description: 'Output result as JSON' }) + parseJson(): boolean { + return true; + } + + @Option({ flags: '--verbose', description: 'Show portal address and raw transaction details' }) + parseVerbose(): boolean { + return true; + } +} diff --git a/src/cli/commands/tokens.command.ts b/src/cli/commands/tokens.command.ts new file mode 100644 index 0000000..cb4b233 --- /dev/null +++ b/src/cli/commands/tokens.command.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { TOKENS } from '@/config/tokens.config'; + +import { DisplayService } from '../services/display.service'; + +@Injectable() +@Command({ name: 'tokens', description: 'List configured tokens' }) +export class TokensCommand extends CommandRunner { + constructor(private readonly display: DisplayService) { + super(); + } + + run(): Promise { + this.display.displayTokens(Object.values(TOKENS)); + return Promise.resolve(); + } +} diff --git a/src/cli/services/display.module.ts b/src/cli/services/display.module.ts new file mode 100644 index 0000000..751978c --- /dev/null +++ b/src/cli/services/display.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { DisplayService } from './display.service'; + +@Module({ + providers: [DisplayService], + exports: [DisplayService], +}) +export class DisplayModule {} diff --git a/src/cli/services/display.service.ts b/src/cli/services/display.service.ts new file mode 100644 index 0000000..facfa00 --- /dev/null +++ b/src/cli/services/display.service.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common'; + +import chalk from 'chalk'; +import Table from 'cli-table3'; +import ora, { Ora } from 'ora'; +import { formatUnits } from 'viem'; + +import { IntentStatus, PublishResult } from '@/blockchain/base.publisher'; +import { TokenConfig } from '@/config/tokens.config'; +import { QuoteResult } from '@/quote/quote.service'; +import { ChainConfig } from '@/shared/types'; + +@Injectable() +export class DisplayService { + private activeSpinner: Ora | null = null; + + spinner(text: string): void { + this.stopSpinner(); + this.activeSpinner = ora(text).start(); + } + + succeed(text?: string): void { + if (this.activeSpinner) { + this.activeSpinner.succeed(text); + this.activeSpinner = null; + } else { + console.log(chalk.green(`✓ ${text}`)); + } + } + fail(text?: string): void { + if (this.activeSpinner) { + this.activeSpinner.fail(text); + this.activeSpinner = null; + } else { + console.error(chalk.red(`✗ ${text}`)); + } + } + warn(text?: string): void { + if (this.activeSpinner) { + this.activeSpinner.warn(text); + this.activeSpinner = null; + } else { + console.warn(chalk.yellow(`⚠ ${text}`)); + } + } + stopSpinner(): void { + this.activeSpinner?.stop(); + this.activeSpinner = null; + } + + log(msg: string): void { + if (this.activeSpinner) { + this.activeSpinner.stop(); + console.log(chalk.gray(msg)); + this.activeSpinner.start(); + } else { + console.log(chalk.gray(msg)); + } + } + success(msg: string): void { + console.log(chalk.green(`✅ ${msg}`)); + } + error(msg: string): void { + console.error(chalk.red(`❌ ${msg}`)); + } + warning(msg: string): void { + console.warn(chalk.yellow(`⚠️ ${msg}`)); + } + title(msg: string): void { + console.log(chalk.bold.blue(msg)); + } + section(msg: string): void { + console.log(chalk.blue(msg)); + } + + displayTable(headers: string[], rows: string[][]): void { + const table = new Table({ head: headers.map(h => chalk.cyan(h)), style: { border: ['gray'] } }); + rows.forEach(row => table.push(row)); + console.log(table.toString()); + } + + displayTransactionResult(result: PublishResult): void { + this.displayTable( + ['Field', 'Value'], + [ + ['Transaction Hash', result.transactionHash ?? '-'], + ['Intent Hash', result.intentHash ?? '-'], + ['Vault Address', result.vaultAddress ?? '-'], + ] + ); + } + + displayFulfillmentResult(status: IntentStatus): void { + this.displayTable( + ['Field', 'Value'], + [ + ['Fulfillment Tx', status.fulfillmentTxHash ?? '-'], + ['Solver', status.solver ?? '-'], + ['Block', status.blockNumber?.toString() ?? '-'], + ] + ); + } + + displayQuote( + quote: QuoteResult, + sourceToken: { symbol?: string; decimals: number }, + sourceAmount: bigint, + destToken: { symbol?: string; decimals: number } + ): void { + const srcSymbol = sourceToken.symbol ?? 'tokens'; + const dstSymbol = destToken.symbol ?? 'tokens'; + const rows: string[][] = [ + ['Source Token', srcSymbol], + ['Source Amount', `${formatUnits(sourceAmount, sourceToken.decimals)} ${srcSymbol}`], + ['Destination Token', dstSymbol], + [ + 'Destination Amount', + `${formatUnits(BigInt(quote.destinationAmount), destToken.decimals)} ${dstSymbol}`, + ], + ['Portal', quote.sourcePortal], + ['Prover', quote.prover], + ['Deadline', new Date(quote.deadline * 1000).toLocaleString()], + ]; + if (quote.estimatedFulfillTimeSec !== undefined) { + rows.push(['Est. Fulfill Time', `${quote.estimatedFulfillTimeSec}s`]); + } + this.displayTable(['Quote Summary', ''], rows); + } + + displayChains(chains: ChainConfig[]): void { + this.displayTable( + ['Name', 'ID', 'Type', 'Native Currency'], + chains.map(c => [c.name, c.id.toString(), c.type, c.nativeCurrency.symbol]) + ); + } + + displayTokens(tokens: TokenConfig[]): void { + this.displayTable( + ['Symbol', 'Name', 'Decimals', 'Available Chains'], + tokens.map(t => [ + t.symbol, + t.name, + t.decimals.toString(), + Object.keys(t.addresses).join(', '), + ]) + ); + } +} diff --git a/src/cli/services/prompt.service.ts b/src/cli/services/prompt.service.ts new file mode 100644 index 0000000..12f899c --- /dev/null +++ b/src/cli/services/prompt.service.ts @@ -0,0 +1,203 @@ +import { Injectable } from '@nestjs/common'; + +import inquirer from 'inquirer'; +import { parseUnits } from 'viem'; + +import { AddressNormalizerService } from '@/blockchain/address-normalizer.service'; +import { ChainRegistryService } from '@/blockchain/chain-registry.service'; +import { TokenConfig } from '@/config/tokens.config'; +import { ChainConfig } from '@/shared/types'; + +@Injectable() +export class PromptService { + constructor( + private readonly registry: ChainRegistryService, + private readonly normalizer: AddressNormalizerService + ) {} + + async selectChain(chains: ChainConfig[], message: string): Promise { + const { chain } = await inquirer.prompt([ + { + type: 'list', + name: 'chain', + message, + choices: chains.map(c => ({ name: `${c.name} (${c.id})`, value: c })), + }, + ]); + return chain; + } + + async selectToken( + chain: ChainConfig, + tokens: TokenConfig[], + label: string + ): Promise<{ address: string; decimals: number; symbol?: string }> { + const availableTokens = tokens.filter(t => !!t.addresses[chain.id.toString()]); + const choices = [ + ...availableTokens.map(t => ({ name: `${t.symbol} - ${t.name}`, value: t.symbol })), + { name: 'Custom Token Address', value: 'CUSTOM' }, + ]; + + const { tokenChoice } = await inquirer.prompt([ + { + type: 'list', + name: 'tokenChoice', + message: `Select ${label} token:`, + choices, + }, + ]); + + if (tokenChoice === 'CUSTOM') { + const handler = this.registry.get(chain.type); + const { address, decimals } = await inquirer.prompt([ + { + type: 'input', + name: 'address', + message: 'Enter token address:', + validate: (input: string) => { + if (!handler.validateAddress(input)) { + return `Invalid ${chain.type} address — expected ${handler.getAddressFormat()}`; + } + return true; + }, + }, + { + type: 'input', + name: 'decimals', + message: 'Enter token decimals (e.g., 18 for most ERC20, 6 for USDC):', + default: '18', + validate: (input: string) => { + const num = parseInt(input); + return !isNaN(num) && num >= 0 && num <= 255 + ? true + : 'Please enter a valid number between 0 and 255'; + }, + }, + ]); + return { address: address as string, decimals: parseInt(decimals as string) }; + } + + const token = availableTokens.find(t => t.symbol === tokenChoice); + if (!token) throw new Error(`Token ${tokenChoice as string} not found`); + + const tokenAddress = token.addresses[chain.id.toString()]; + if (!tokenAddress) throw new Error(`Token ${token.symbol} not available on chain ${chain.id}`); + + return { + address: this.normalizer.denormalize(tokenAddress, chain.type) as string, + decimals: token.decimals, + symbol: token.symbol, + }; + } + + async inputAmount( + label: string, + decimals: number, + defaultValue = '0.1' + ): Promise<{ raw: string; parsed: bigint }> { + const { amount } = await inquirer.prompt([ + { + type: 'input', + name: 'amount', + message: `Enter ${label} amount in human-readable format (e.g., "10" for 10 tokens):`, + default: defaultValue, + validate: (input: string) => { + const num = parseFloat(input); + return !isNaN(num) && num > 0 ? true : 'Please enter a positive number'; + }, + }, + ]); + return { + raw: amount as string, + parsed: parseUnits(amount as string, decimals), + }; + } + + async inputAddress(chain: ChainConfig, label: string, defaultValue?: string): Promise { + const handler = this.registry.get(chain.type); + const { address } = await inquirer.prompt([ + { + type: 'input', + name: 'address', + message: `Enter ${label} address on ${chain.name} (${chain.type} chain):`, + default: defaultValue, + validate: (input: string) => { + if (!input || input.trim() === '') return `${label} address is required`; + if (!handler.validateAddress(input)) { + return `Invalid ${chain.type} address — expected ${handler.getAddressFormat()}`; + } + return true; + }, + }, + ]); + return address as string; + } + + async confirmPublish(): Promise { + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: 'Publish this intent?', + default: true, + }, + ]); + return confirmed; + } + + async confirm(message: string, defaultValue = false): Promise { + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message, + default: defaultValue, + }, + ]); + return confirmed; + } + + async inputManualPortal(chain: ChainConfig): Promise { + const handler = this.registry.get(chain.type); + const { portal } = await inquirer.prompt([ + { + type: 'input', + name: 'portal', + message: `Enter portal contract address on ${chain.name}:`, + default: chain.portalAddress + ? (this.normalizer.denormalize(chain.portalAddress, chain.type) as string) + : undefined, + validate: (input: string) => { + if (!input || input.trim() === '') return 'Portal address is required'; + if (!handler.validateAddress(input)) { + return `Invalid ${chain.type} address — expected ${handler.getAddressFormat()}`; + } + return true; + }, + }, + ]); + return portal as string; + } + + async inputManualProver(chain: ChainConfig): Promise { + const handler = this.registry.get(chain.type); + const { prover } = await inquirer.prompt([ + { + type: 'input', + name: 'prover', + message: `Enter prover contract address on ${chain.name}:`, + default: chain.proverAddress + ? (this.normalizer.denormalize(chain.proverAddress, chain.type) as string) + : undefined, + validate: (input: string) => { + if (!input || input.trim() === '') return 'Prover address is required'; + if (!handler.validateAddress(input)) { + return `Invalid ${chain.type} address — expected ${handler.getAddressFormat()}`; + } + return true; + }, + }, + ]); + return prover as string; + } +} diff --git a/src/commands/config.ts b/src/commands/config.ts deleted file mode 100644 index b60f5b4..0000000 --- a/src/commands/config.ts +++ /dev/null @@ -1,535 +0,0 @@ -/** - * Config Command - */ - -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { Command } from 'commander'; -import inquirer from 'inquirer'; - -import { ChainType } from '@/core/interfaces/intent'; -import { logger } from '@/utils/logger'; - -interface ConfigSettings { - defaultSourceChain?: string; - defaultDestinationChain?: string; - defaultPrivateKeys?: { - [ChainType.EVM]?: string; - [ChainType.TVM]?: string; - [ChainType.SVM]?: string; - }; - rpcUrls?: Record; - profiles?: Record; - currentProfile?: string; -} - -const CONFIG_DIR = path.join(os.homedir(), '.eco-routes'); -const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); -const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles'); - -export function createConfigCommand(): Command { - const command = new Command('config'); - - command.description('Manage CLI configuration settings'); - - // List all configuration - command - .command('list') - .description('List current configuration') - .option('--profile ', 'Show configuration for specific profile') - .action(async options => { - try { - const config = loadConfig(); - - if (options.profile) { - if (!config.profiles?.[options.profile]) { - logger.error(`Profile '${options.profile}' not found`); - process.exit(1); - } - - logger.title(`📋 Profile: ${options.profile}`); - displayConfig(config.profiles[options.profile]); - } else { - logger.title('📋 Current Configuration'); - if (config.currentProfile) { - logger.info(`Active Profile: ${config.currentProfile}`); - logger.info(''); - } - displayConfig(config); - - if (config.profiles && Object.keys(config.profiles).length > 0) { - logger.section('Available Profiles'); - Object.keys(config.profiles).forEach(name => { - const isActive = name === config.currentProfile ? ' (active)' : ''; - logger.info(`• ${name}${isActive}`); - }); - } - } - } catch (error) { - logger.error( - `Error reading configuration: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - // Set configuration values - command - .command('set') - .description('Set configuration values') - .argument('[key]', 'Configuration key (e.g., defaultSourceChain)') - .argument('[value]', 'Configuration value') - .option('--profile ', 'Set value for specific profile') - .option('-i, --interactive', 'Interactive mode') - .action(async (key, value, options) => { - try { - if (options.interactive || (!key && !value)) { - await setConfigInteractive(options.profile); - } else if (key && value !== undefined) { - await setConfigValue(key, value, options.profile); - } else { - logger.error('Please provide both key and value, or use --interactive mode'); - process.exit(1); - } - } catch (error) { - logger.error( - `Error setting configuration: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - // Get configuration value - command - .command('get') - .description('Get configuration value') - .argument('', 'Configuration key') - .option('--profile ', 'Get value from specific profile') - .action((key, options) => { - try { - const config = loadConfig(); - const targetConfig = options.profile ? config.profiles?.[options.profile] || {} : config; - - const value = getNestedValue(targetConfig, key); - - if (value !== undefined) { - // Mask private keys for security - if (key.toLowerCase().includes('private')) { - logger.log('***[HIDDEN]***'); - } else { - logger.log(String(value)); - } - } else { - logger.warn(`Configuration key '${key}' not found`); - process.exit(1); - } - } catch (error) { - logger.error( - `Error getting configuration: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - // Remove configuration key - command - .command('unset') - .description('Remove configuration key') - .argument('', 'Configuration key to remove') - .option('--profile ', 'Remove from specific profile') - .action(async (key, options) => { - try { - await unsetConfigValue(key, options.profile); - logger.success(`Configuration key '${key}' removed`); - } catch (error) { - logger.error( - `Error removing configuration: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - // Profile management - create profile subcommand - const profileCommand = command.command('profile').description('Manage configuration profiles'); - - profileCommand - .command('create ') - .description('Create a new profile') - .action(async name => { - try { - await createProfile(name); - logger.success(`Profile '${name}' created`); - } catch (error) { - logger.error( - `Error creating profile: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - profileCommand - .command('switch ') - .description('Switch to a profile') - .action(async name => { - try { - await switchProfile(name); - logger.success(`Switched to profile '${name}'`); - } catch (error) { - logger.error( - `Error switching profile: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - profileCommand - .command('delete ') - .description('Delete a profile') - .option('--force', 'Skip confirmation') - .action(async (name, options) => { - try { - if (!options.force) { - const { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: `Are you sure you want to delete profile '${name}'?`, - default: false, - }, - ]); - - if (!confirm) { - logger.info('Profile deletion cancelled'); - return; - } - } - - await deleteProfile(name); - logger.success(`Profile '${name}' deleted`); - } catch (error) { - logger.error( - `Error deleting profile: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - profileCommand - .command('list') - .description('List available profiles') - .action(() => { - try { - const config = loadConfig(); - if (!config.profiles || Object.keys(config.profiles).length === 0) { - logger.info('No profiles found'); - return; - } - - logger.title('📋 Available Profiles'); - Object.keys(config.profiles).forEach(name => { - const isActive = name === config.currentProfile ? ' (active)' : ''; - logger.info(`• ${name}${isActive}`); - }); - } catch (error) { - logger.error( - `Error listing profiles: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - // Reset configuration - command - .command('reset') - .description('Reset configuration to defaults') - .option('--profile ', 'Reset specific profile') - .option('--force', 'Skip confirmation') - .action(async options => { - try { - if (!options.force) { - const target = options.profile ? `profile '${options.profile}'` : 'entire configuration'; - const { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: `Are you sure you want to reset ${target}?`, - default: false, - }, - ]); - - if (!confirm) { - logger.info('Reset cancelled'); - return; - } - } - - await resetConfig(options.profile); - logger.success( - options.profile ? `Profile '${options.profile}' reset` : 'Configuration reset' - ); - } catch (error) { - logger.error( - `Error resetting configuration: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } - }); - - return command; -} - -function loadConfig(): ConfigSettings { - if (!fs.existsSync(CONFIG_FILE)) { - return {}; - } - - try { - const content = fs.readFileSync(CONFIG_FILE, 'utf-8'); - return JSON.parse(content); - } catch (error) { - throw new Error( - `Failed to parse config file: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -function saveConfig(config: ConfigSettings): void { - ensureConfigDir(); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); -} - -function ensureConfigDir(): void { - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); - } - if (!fs.existsSync(PROFILES_DIR)) { - fs.mkdirSync(PROFILES_DIR, { recursive: true }); - } -} - -function displayConfig(config: ConfigSettings): void { - const data: Record = {}; - - if (config.defaultSourceChain) data['Default Source Chain'] = config.defaultSourceChain; - if (config.defaultDestinationChain) - data['Default Destination Chain'] = config.defaultDestinationChain; - - // Show RPC URLs - if (config.rpcUrls && Object.keys(config.rpcUrls).length > 0) { - Object.entries(config.rpcUrls).forEach(([chain, url]) => { - data[`RPC URL (${chain})`] = url; - }); - } - - // Show private key status (masked) - if (config.defaultPrivateKeys) { - Object.entries(config.defaultPrivateKeys).forEach(([chainType, key]) => { - if (key) { - data[`Private Key (${chainType})`] = '***[SET]***'; - } - }); - } - - if (Object.keys(data).length === 0) { - logger.info('No configuration set'); - } else { - logger.displayKeyValue(data); - } -} - -async function setConfigInteractive(profileName?: string): Promise { - const config = loadConfig(); - const targetConfig = profileName ? config.profiles?.[profileName] || {} : config; - - logger.title('🔧 Interactive Configuration Setup'); - - if (profileName) { - logger.info(`Configuring profile: ${profileName}`); - } - - const questions = [ - { - type: 'input', - name: 'defaultSourceChain', - message: 'Default source chain (name or ID):', - default: targetConfig.defaultSourceChain, - }, - { - type: 'input', - name: 'defaultDestinationChain', - message: 'Default destination chain (name or ID):', - default: targetConfig.defaultDestinationChain, - }, - { - type: 'password', - name: 'evmPrivateKey', - message: 'EVM private key (optional):', - mask: '*', - }, - { - type: 'password', - name: 'tvmPrivateKey', - message: 'TVM private key (optional):', - mask: '*', - }, - { - type: 'password', - name: 'svmPrivateKey', - message: 'SVM private key (optional):', - mask: '*', - }, - ]; - - const answers = await inquirer.prompt(questions); - - // Update configuration - if (answers.defaultSourceChain) targetConfig.defaultSourceChain = answers.defaultSourceChain; - if (answers.defaultDestinationChain) - targetConfig.defaultDestinationChain = answers.defaultDestinationChain; - - if (!targetConfig.defaultPrivateKeys) targetConfig.defaultPrivateKeys = {}; - if (answers.evmPrivateKey) targetConfig.defaultPrivateKeys[ChainType.EVM] = answers.evmPrivateKey; - if (answers.tvmPrivateKey) targetConfig.defaultPrivateKeys[ChainType.TVM] = answers.tvmPrivateKey; - if (answers.svmPrivateKey) targetConfig.defaultPrivateKeys[ChainType.SVM] = answers.svmPrivateKey; - - if (profileName) { - if (!config.profiles) config.profiles = {}; - config.profiles[profileName] = targetConfig; - } else { - Object.assign(config, targetConfig); - } - - saveConfig(config); - logger.success('Configuration updated successfully'); -} - -async function setConfigValue(key: string, value: string, profileName?: string): Promise { - const config = loadConfig(); - const targetConfig = profileName ? config.profiles?.[profileName] || {} : config; - - setNestedValue(targetConfig, key, value); - - if (profileName) { - if (!config.profiles) config.profiles = {}; - config.profiles[profileName] = targetConfig; - } else { - Object.assign(config, targetConfig); - } - - saveConfig(config); - logger.success(`Configuration key '${key}' set to '${value}'`); -} - -async function unsetConfigValue(key: string, profileName?: string): Promise { - const config = loadConfig(); - const targetConfig = profileName ? config.profiles?.[profileName] || {} : config; - - deleteNestedValue(targetConfig, key); - - if (profileName) { - if (!config.profiles) config.profiles = {}; - config.profiles[profileName] = targetConfig; - } else { - Object.assign(config, targetConfig); - } - - saveConfig(config); -} - -async function createProfile(name: string): Promise { - const config = loadConfig(); - - if (!config.profiles) config.profiles = {}; - if (config.profiles[name]) { - throw new Error(`Profile '${name}' already exists`); - } - - config.profiles[name] = {}; - saveConfig(config); -} - -async function switchProfile(name: string): Promise { - const config = loadConfig(); - - if (!config.profiles?.[name]) { - throw new Error(`Profile '${name}' does not exist`); - } - - config.currentProfile = name; - saveConfig(config); -} - -async function deleteProfile(name: string): Promise { - const config = loadConfig(); - - if (!config.profiles?.[name]) { - throw new Error(`Profile '${name}' does not exist`); - } - - delete config.profiles[name]; - if (config.currentProfile === name) { - delete config.currentProfile; - } - - saveConfig(config); -} - -async function resetConfig(profileName?: string): Promise { - if (profileName) { - const config = loadConfig(); - if (config.profiles?.[profileName]) { - config.profiles[profileName] = {}; - saveConfig(config); - } - } else { - if (fs.existsSync(CONFIG_FILE)) { - fs.unlinkSync(CONFIG_FILE); - } - } -} - -// Utility functions for nested object operations -function getNestedValue(obj: ConfigSettings | Record, path: string): unknown { - return path.split('.').reduce((current: unknown, key: string) => { - if (current && typeof current === 'object' && key in current) { - return (current as Record)[key]; - } - return undefined; - }, obj); -} - -function setNestedValue( - obj: ConfigSettings | Record, - path: string, - value: unknown -): void { - const keys = path.split('.'); - const lastKey = keys.pop()!; - const target = keys.reduce( - (current: Record, key: string) => { - if (!current[key] || typeof current[key] !== 'object') { - current[key] = {}; - } - return current[key] as Record; - }, - obj as Record - ); - target[lastKey] = value; -} - -function deleteNestedValue(obj: ConfigSettings | Record, path: string): void { - const keys = path.split('.'); - const lastKey = keys.pop()!; - const target = keys.reduce((current: unknown, key: string) => { - if (current && typeof current === 'object' && key in current) { - return (current as Record)[key]; - } - return undefined; - }, obj as unknown); - - if (target && typeof target === 'object' && lastKey in target) { - delete (target as Record)[lastKey]; - } -} diff --git a/src/commands/publish.ts b/src/commands/publish.ts deleted file mode 100644 index 91b49db..0000000 --- a/src/commands/publish.ts +++ /dev/null @@ -1,671 +0,0 @@ -/** - * Publish Command - */ - -import * as crypto from 'crypto'; - -import { Keypair, PublicKey } from '@solana/web3.js'; -import { Command } from 'commander'; -import inquirer from 'inquirer'; -import { TronWeb } from 'tronweb'; -import { - encodeFunctionData, - erc20Abi, - formatUnits, - Hex, - isAddress as isViemAddress, - parseUnits, -} from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; - -import { BasePublisher } from '@/blockchain/base-publisher'; -import { EvmPublisher } from '@/blockchain/evm-publisher'; -import { SvmPublisher } from '@/blockchain/svm-publisher'; -import { TvmPublisher } from '@/blockchain/tvm-publisher'; -import { serialize } from '@/commons/utils/serialize'; -import { ChainConfig, getChainById, getChainByName, listChains } from '@/config/chains'; -import { loadEnvConfig } from '@/config/env'; -import { getTokenAddress, getTokenBySymbol, listTokens } from '@/config/tokens'; -import { ChainType, Intent } from '@/core/interfaces/intent'; -import { BlockchainAddress, SvmAddress, TronAddress } from '@/core/types/blockchain-addresses'; -import { UniversalAddress } from '@/core/types/universal-address'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; -import { PortalEncoder } from '@/core/utils/portal-encoder'; -import { getQuote, QuoteResponse } from '@/core/utils/quote'; -import { logger } from '@/utils/logger'; - -interface PublishCommandOptions { - source?: string; - destination?: string; - privateKey?: string; - rpc?: string; - dryRun?: boolean; -} - -export function createPublishCommand(): Command { - const command = new Command('publish'); - - command - .description('Publish an intent to the blockchain') - .option('-s, --source ', 'Source chain (name or ID)') - .option('-d, --destination ', 'Destination chain (name or ID)') - .option('-k, --private-key ', 'Private key (overrides env)') - .option('-r, --rpc ', 'RPC URL (overrides env)') - .option('--recipient
', 'Recipient address on destination chain') - .option('--dry-run', 'Validate without publishing') - .action(async options => { - try { - // Interactive mode - logger.title('🎨 Interactive Intent Publishing'); - - const { reward, encodedRoute, sourceChain, destChain, sourcePortal } = - await buildIntentInteractively(options); - - if (process.env.DEBUG) { - logger.log(`Reward: ${serialize(reward)}`); - } - - const privateKey = getPrivateKey(sourceChain); - - // Determine RPC URL - const rpcUrl = options.rpc || sourceChain.rpcUrl; - - // Create publisher based on source chain type - let publisher: BasePublisher; - switch (sourceChain.type) { - case ChainType.EVM: - publisher = new EvmPublisher(rpcUrl); - break; - case ChainType.TVM: - publisher = new TvmPublisher(rpcUrl); - break; - case ChainType.SVM: - publisher = new SvmPublisher(rpcUrl); - break; - default: - throw new Error(`Unsupported chain type: ${sourceChain.type}`); - } - - // Get sender address - const senderAddress = getWalletAddr(sourceChain, options); - - logger.log(`Sender: ${senderAddress}`); - logger.log(`Source: ${sourceChain.name} (${sourceChain.id})`); - logger.log(`Destination: ${destChain.name} (${destChain.id})`); - - if (options.dryRun) { - logger.warning('Dry run - not publishing'); - return; - } - - // Publish - logger.spinner('Publishing intent to blockchain...'); - const result = await publisher.publish( - sourceChain.id, - destChain.id, - reward, - encodedRoute, - privateKey, - sourcePortal - ); - - if (result.success) { - logger.displayTransactionResult(result); - } else { - logger.fail('Publishing failed'); - throw new Error(result.error || 'Publishing failed'); - } - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - logger.error(`Error: ${errorMessage}`); - if (process.env.DEBUG && error instanceof Error) { - logger.error(`Stack: ${error.stack}`); - } - process.exit(1); - } - }); - - return command; -} - -/** - * Build intent interactively - */ -async function buildIntentInteractively(options: PublishCommandOptions) { - const chains = listChains(); - - // 1. Get source chain - let sourceChain: ChainConfig | undefined; - if (options.source) { - sourceChain = getChainByName(options.source) || getChainById(BigInt(options.source)); - if (!sourceChain) { - throw new Error(`Unknown source chain: ${options.source}`); - } - } else { - const { source: sourceId } = await inquirer.prompt([ - { - type: 'list', - name: 'source', - message: 'Select source chain:', - choices: chains.map(c => ({ - name: `${c.name} (${c.id})`, - value: c.id, - })), - }, - ]); - - sourceChain = getChainById(BigInt(sourceId))!; - } - - // 2. Get destination chain - let destChain: ChainConfig | undefined; - if (options.destination) { - destChain = getChainByName(options.destination) || getChainById(BigInt(options.destination)); - if (!destChain) { - throw new Error(`Unknown destination chain: ${options.destination}`); - } - } else { - const { destination: destinationId } = await inquirer.prompt([ - { - type: 'list', - name: 'destination', - message: 'Select destination chain:', - choices: chains - .filter(c => c.id !== sourceChain!.id) - .map(c => ({ name: `${c.name} (${c.id})`, value: c.id })), - }, - ]); - destChain = getChainById(destinationId)!; - } - - // 4. Prompt for reward configuration - logger.section('💰 Reward Configuration (Source Chain)'); - - const rewardToken = await selectToken(sourceChain, 'reward'); - - const { rewardAmountStr } = await inquirer.prompt([ - { - type: 'input', - name: 'rewardAmountStr', - default: '0.1', - message: `Enter reward amount${rewardToken.symbol ? ` (${rewardToken.symbol})` : ''} in human-readable format (e.g., "10" for 10 tokens):`, - validate: input => { - try { - const num = parseFloat(input); - if (isNaN(num) || num <= 0) { - return 'Please enter a positive number'; - } - return true; - } catch { - return 'Invalid amount'; - } - }, - }, - ]); - - // Convert human-readable amount to token units using parseUnits - const rewardAmount = parseUnits(rewardAmountStr, rewardToken.decimals); - - // 7. Prompt for route configuration - logger.section('📏 Route Configuration (Destination Chain)'); - - const routeToken = await selectToken(destChain, 'route'); - - // 7. Prompt for recipient address - logger.section('👤 Recipient Configuration'); - - let defaultRecipient: string | undefined; - try { - defaultRecipient = getWalletAddr(destChain, options); - } catch { - // Ignore default recipient - } - - const { recipientAddress } = await inquirer.prompt([ - { - type: 'input', - name: 'recipientAddress', - message: `Enter recipient address on ${destChain.name} (${destChain.type} chain):`, - default: defaultRecipient, - validate: input => { - if (!input || input.trim() === '') { - return 'Recipient address is required'; - } - - try { - // Validate the address format based on destination chain type - switch (destChain.type) { - case ChainType.EVM: - if (!isViemAddress(input)) { - return `Invalid EVM address format. Expected format: 0x... (40 hex characters after 0x)`; - } - break; - case ChainType.TVM: - if (!TronWeb.isAddress(input)) { - return `Invalid Tron address format. Expected format: T... (base58) or 41... (hex)`; - } - break; - case ChainType.SVM: - try { - new PublicKey(input); - } catch { - return `Invalid Solana address format. Expected format: base58 encoded public key`; - } - break; - default: - return `Unsupported destination chain type: ${destChain.type}`; - } - - // Try to normalize the address to ensure it's fully valid - AddressNormalizer.normalize(input, destChain.type); - return true; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Invalid address format'; - return `Invalid address: ${errorMessage}`; - } - }, - }, - ]); - - // 3. Get wallet address (creator) from private key - const creatorAddress = AddressNormalizer.normalize( - getWalletAddr(sourceChain, options), - sourceChain.type - ); - - // Normalize the recipient address - const normalizedRecipient = AddressNormalizer.normalize(recipientAddress, destChain.type); - - // 5. Get quote (with fallback to manual configuration) - let quote: QuoteResponse | null = null; - - logger.spinner('Getting quote...'); - try { - quote = await getQuote({ - source: sourceChain.id, - destination: destChain.id, - funder: AddressNormalizer.denormalize(creatorAddress, sourceChain.type), - recipient: AddressNormalizer.denormalize(normalizedRecipient, destChain.type), - amount: rewardAmount, - routeToken: routeToken.address, - rewardToken: rewardToken.address, - }); - - logger.succeed('Quote fetched'); - - // Validate contract addresses from quote - if (quote && (!quote.contracts?.sourcePortal || !quote.contracts?.prover)) { - logger.warning('Quote response missing required contract addresses'); - quote = null; - } - } catch (error: any) { - logger.stopSpinner(); - if (process.env.DEBUG) { - console.log(error.stack); - } - logger.warning('Quote service unavailable'); - quote = null; - } - - // Variables to hold route/reward data - let encodedRoute!: Hex; - let sourcePortal!: UniversalAddress; - let proverAddress!: UniversalAddress; - let routeAmountDisplay!: string; - - // 6. Set fixed deadlines - const now = Math.floor(Date.now() / 1000); - let rewardDeadline = BigInt(now + 2.5 * 60 * 60); - - if (quote) { - // Extract quote data (now unified format from both APIs) - const quoteData = quote.quoteResponse; - - if (!quoteData) { - logger.warning('Quote response missing quote data'); - quote = null; - } else { - encodedRoute = quoteData.encodedRoute as Hex; - sourcePortal = AddressNormalizer.normalize(quote.contracts.sourcePortal, sourceChain.type); - proverAddress = AddressNormalizer.normalize(quote.contracts.prover, sourceChain.type); - routeAmountDisplay = formatUnits(BigInt(quoteData.destinationAmount), routeToken.decimals); - rewardDeadline = BigInt(quoteData.deadline); - - // Display solver-v2 specific fields if available - if (quoteData.estimatedFulfillTimeSec) { - logger.info(`Estimated fulfillment time: ${quoteData.estimatedFulfillTimeSec} seconds`); - } - - if (quoteData.intentExecutionType) { - logger.info(`Execution type: ${quoteData.intentExecutionType}`); - } - } - } - - if (!quote) { - // FALLBACK: Manual configuration - logger.section('⚠️ Manual Configuration Required'); - - // Display detailed warning - logger.warning('Quote service is unavailable. Manual configuration is required.'); - logger.log(''); - logger.log('⚠️ Important:'); - logger.log(' • You must provide the route amount manually'); - logger.log(' • Portal and prover addresses will be needed'); - logger.log(' • Routing may not be optimal without quote service'); - logger.log(''); - - const { proceedManual } = await inquirer.prompt([ - { - type: 'confirm', - name: 'proceedManual', - message: 'Do you want to proceed with manual configuration?', - default: true, - }, - ]); - - if (!proceedManual) { - throw new Error('Publication cancelled by user'); - } - - // Prompt for route amount - const { routeAmountStr } = await inquirer.prompt([ - { - type: 'input', - name: 'routeAmountStr', - message: `Enter expected route amount (tokens to receive on ${destChain.name}):`, - validate: input => { - try { - const num = parseFloat(input); - if (isNaN(num) || num <= 0) { - return 'Please enter a positive number'; - } - return true; - } catch { - return 'Invalid amount'; - } - }, - }, - ]); - - const routeAmount = parseUnits(routeAmountStr, routeToken.decimals); - routeAmountDisplay = routeAmountStr; - - // Get or prompt for portal address - if (sourceChain.portalAddress) { - sourcePortal = sourceChain.portalAddress; - logger.log(`Using portal address from config: ${sourcePortal}`); - } else { - const { portalAddressInput } = await inquirer.prompt([ - { - type: 'input', - name: 'portalAddressInput', - message: `Enter source portal address for ${sourceChain.name}:`, - validate: input => { - try { - AddressNormalizer.normalize(input, sourceChain.type); - return true; - } catch { - return 'Invalid address format'; - } - }, - }, - ]); - sourcePortal = AddressNormalizer.normalize(portalAddressInput, sourceChain.type); - } - - // Get or prompt for prover address - if (sourceChain.proverAddress) { - proverAddress = sourceChain.proverAddress; - logger.log(`Using prover address from config: ${proverAddress}`); - } else { - const { proverAddressInput } = await inquirer.prompt([ - { - type: 'input', - name: 'proverAddressInput', - message: `Enter prover address for ${sourceChain.name}:`, - validate: input => { - try { - AddressNormalizer.normalize(input, sourceChain.type); - return true; - } catch { - return 'Invalid address format'; - } - }, - }, - ]); - proverAddress = AddressNormalizer.normalize(proverAddressInput, sourceChain.type); - } - - // Build Route object manually - logger.spinner('Building route manually...'); - - const now = Math.floor(Date.now() / 1000); - const routeDeadline = BigInt(now + 2 * 60 * 60); // 2 hours - - // Encode transfer function call for route token - const transferCallData = encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [ - AddressNormalizer.denormalize(normalizedRecipient, destChain.type) as `0x${string}`, - routeAmount, - ], - }); - - const route: Intent['route'] = { - salt: `0x${crypto.randomBytes(32).toString('hex')}` as Hex, - deadline: routeDeadline, - portal: sourcePortal, - nativeAmount: 0n, - tokens: [ - { - token: AddressNormalizer.normalize(routeToken.address, destChain.type), - amount: routeAmount, - }, - ], - calls: [ - { - target: AddressNormalizer.normalize(routeToken.address, destChain.type), - data: transferCallData, - value: 0n, - }, - ], - }; - - // Encode the route - encodedRoute = PortalEncoder.encode(route, destChain.type); - logger.succeed('Route built and encoded'); - } - - // 7. Build reward using addresses from quote or manual input - const reward: Intent['reward'] = { - deadline: rewardDeadline, - prover: proverAddress, - creator: creatorAddress, - nativeAmount: 0n, - tokens: [ - { - token: AddressNormalizer.normalize(rewardToken.address, sourceChain.type), - amount: rewardAmount, - }, - ], - }; - - logger.displayIntentSummary({ - source: `${sourceChain.name} (${sourceChain.id})`, - destination: `${destChain.name} (${destChain.id})`, - creator: AddressNormalizer.denormalize(creatorAddress, sourceChain.type), - recipient: normalizedRecipient, - rewardDeadline: new Date(Number(rewardDeadline) * 1000).toLocaleString(), - routeToken: `${routeToken.address}${routeToken.symbol ? ` (${routeToken.symbol})` : ''}`, - routeAmount: routeAmountDisplay, - rewardToken: `${rewardToken.address}${rewardToken.symbol ? ` (${rewardToken.symbol})` : ''}`, - rewardAmount: `${rewardAmountStr} (${rewardAmount.toString()} units)`, - }); - - const { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: 'Publish this intent?', - default: true, - }, - ]); - - if (!confirm) { - throw new Error('Publication cancelled by user'); - } - - return { - reward, - encodedRoute, - sourceChain, - destChain, - sourcePortal, - }; -} - -/** - * Select a token for a specific chain - */ -async function selectToken( - chain: ChainConfig, - type: string -): Promise<{ address: BlockchainAddress; decimals: number; symbol?: string }> { - // Get available tokens for this chain - const allTokens = listTokens(); - const chainTokens = allTokens.filter(token => { - const address = getTokenAddress(token.symbol, chain.id); - return address !== undefined; - }); - - const choices = [ - ...chainTokens.map(t => ({ - name: `${t.symbol} - ${t.name}`, - value: t.symbol, - })), - { name: 'Custom Token Address', value: 'CUSTOM' }, - ]; - - const { tokenChoice } = await inquirer.prompt([ - { - type: 'list', - name: 'tokenChoice', - message: `Select ${type} token:`, - choices, - }, - ]); - - if (tokenChoice === 'CUSTOM') { - const { address, decimals } = await inquirer.prompt([ - { - type: 'input', - name: 'address', - message: 'Enter token address:', - validate: input => { - try { - AddressNormalizer.normalize(input, chain.type); - return true; - } catch { - return 'Invalid address format'; - } - }, - }, - { - type: 'input', - name: 'decimals', - message: 'Enter token decimals (e.g., 18 for most ERC20, 6 for USDC):', - default: '18', - validate: input => { - const num = parseInt(input); - return !isNaN(num) && num >= 0 && num <= 255 - ? true - : 'Please enter a valid number between 0 and 255'; - }, - }, - ]); - return { address, decimals: parseInt(decimals) }; - } - - // Get token config for selected symbol - const tokenConfig = getTokenBySymbol(tokenChoice); - if (!tokenConfig) { - throw new Error(`Token ${tokenChoice} not found`); - } - - const tokenAddress = getTokenAddress(tokenChoice, chain.id); - if (!tokenAddress) { - throw new Error(`Token ${tokenChoice} not available on chain ${chain.id}`); - } - - // Denormalize the token address to chain-native format for display - return { - address: AddressNormalizer.denormalize(tokenAddress, chain.type), - decimals: tokenConfig.decimals, - symbol: tokenConfig.symbol, - }; -} - -export function getWalletAddr( - chain: ChainConfig, - options?: PublishCommandOptions -): BlockchainAddress { - const privateKey = getPrivateKey(chain, options?.privateKey); - - if (!privateKey) { - throw new Error(`No private key configured for ${chain.type} chain`); - } - - switch (chain.type) { - case ChainType.EVM: - const account = privateKeyToAccount(privateKey as Hex); - return account.address; - case ChainType.TVM: - const tronAddress = TronWeb.address.fromPrivateKey(privateKey); - if (!tronAddress) { - throw new Error('Invalid Tron private key'); - } - return tronAddress as TronAddress; - case ChainType.SVM: - let keypair: Keypair; - if (privateKey.startsWith('[')) { - const bytes = JSON.parse(privateKey); - keypair = Keypair.fromSecretKey(new Uint8Array(bytes)); - } else { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const bs58 = require('bs58'); - const bytes = bs58.decode(privateKey); - keypair = Keypair.fromSecretKey(bytes); - } - return keypair.publicKey.toBase58() as SvmAddress; - default: - throw new Error('Unknown chain type'); - } -} - -function getPrivateKey(chain: ChainConfig, privateKey?: string) { - // Load configuration - const env = loadEnvConfig(); - - // Determine private key - if (!privateKey) { - switch (chain.type) { - case ChainType.EVM: - privateKey = env.evmPrivateKey; - break; - case ChainType.TVM: - privateKey = env.tvmPrivateKey; - break; - case ChainType.SVM: - privateKey = env.svmPrivateKey; - break; - } - } - - if (!privateKey) { - throw new Error(`No private key provided for ${chain.type} chain`); - } - - return privateKey; -} diff --git a/src/commands/status.ts b/src/commands/status.ts deleted file mode 100644 index fac5197..0000000 --- a/src/commands/status.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Status Command - */ - -import { Command } from 'commander'; -import { Address, createPublicClient, getContract, Hex, http } from 'viem'; - -import { portalAbi } from '@/commons/abis/portal.abi'; -import { ChainConfig, getChainById, getChainByName } from '@/config/chains'; -import { ChainType } from '@/core/interfaces/intent'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; -import { chalk, logger } from '@/utils/logger'; - -interface IntentStatus { - intentHash: string; - isFulfilled: boolean; - claimant?: string; - transactionHash?: string; - blockNumber?: bigint; - timestamp?: Date; -} - -interface StatusCommandOptions { - chain?: string; - watch?: boolean; - json?: boolean; - verbose?: boolean; -} - -export function createStatusCommand(): Command { - const command = new Command('status'); - - command - .description('Check the fulfillment status of an intent') - .argument('', 'Intent hash to check (0x-prefixed hex string)') - .option('-c, --chain ', 'Destination chain (name or ID)') - .option('-w, --watch', 'Watch for status updates (poll every 30 seconds)') - .option('--json', 'Output in JSON format') - .option('--verbose', 'Show detailed information') - .action(async (intentHashArg: string, options) => { - try { - // Validate intent hash format - if (!intentHashArg.startsWith('0x') || intentHashArg.length !== 66) { - throw new Error('Intent hash must be a 0x-prefixed 64-character hex string'); - } - - const intentHash = intentHashArg as Hex; - - // Get destination chain - let destChain: ChainConfig | undefined; - if (options.chain) { - // Try to get by name first, then by ID - destChain = getChainByName(options.chain) || getChainById(options.chain); - if (!destChain) { - throw new Error(`Unknown chain: ${options.chain}`); - } - } else { - throw new Error('Destination chain is required. Use --chain option.'); - } - - // Only EVM chains are supported for now - if (destChain.type !== ChainType.EVM) { - throw new Error('Status checking is currently only supported for EVM chains'); - } - - if (!options.json && !options.watch) { - logger.title(`🔍 Checking Intent Status`); - logger.info(`Intent Hash: ${intentHash}`); - logger.info(`Chain: ${destChain.name} (${destChain.id})`); - logger.info(''); - } - - if (options.watch) { - await watchIntentStatus(intentHash, destChain, options); - } else { - const status = await getIntentStatus(intentHash, destChain, options.verbose); - displayStatus(status, options); - } - } catch (error) { - if (options.json) { - logger.log( - JSON.stringify( - { error: error instanceof Error ? error.message : String(error) }, - null, - 2 - ) - ); - } else { - logger.error( - `Error checking intent status: ${error instanceof Error ? error.message : String(error)}` - ); - } - process.exit(1); - } - }); - - return command; -} - -async function getIntentStatus( - intentHash: Hex, - chain: ChainConfig, - verbose: boolean = false -): Promise { - // Create public client for the destination chain - const client = createPublicClient({ - chain: { - id: Number(chain.id), - name: chain.name, - network: chain.name.toLowerCase(), - nativeCurrency: chain.nativeCurrency, - rpcUrls: { - default: { http: [chain.rpcUrl] }, - public: { http: [chain.rpcUrl] }, - }, - }, - transport: http(chain.rpcUrl), - }); - - // Get the portal address (denormalized for EVM) - if (!chain.portalAddress) { - throw new Error(`No portal address configured for chain ${chain.name}`); - } - - const portalAddress = AddressNormalizer.denormalize( - chain.portalAddress, - ChainType.EVM - ) as Address; - - if (verbose) { - logger.info(`Querying Portal contract: ${portalAddress}`); - } - - // Create contract instance - const portalContract = getContract({ - address: portalAddress, - abi: portalAbi, - client, - }); - - try { - // TODO: Must query the last 10k blocks in 1k intervals - // Query for IntentFulfilled events - const [event] = await portalContract.getEvents.IntentFulfilled({ - intentHash, - }); - - if (process.env.DEBUG) { - logger.log(`Event: ${JSON.stringify({ event, portalAddress, client: client.chain.name })}`); - } - - const status: IntentStatus = { - intentHash, - isFulfilled: Boolean(event), - }; - - if (status.isFulfilled) { - // Get the most recent fulfillment event - - status.claimant = event.args.claimant; - status.transactionHash = event.transactionHash; - status.blockNumber = event.blockNumber; - - // Get block timestamp - if (event.blockNumber) { - const block = await client.getBlock({ blockNumber: event.blockNumber }); - status.timestamp = new Date(Number(block.timestamp) * 1000); - } - - if (verbose && event.transactionHash) { - logger.info(`Fulfillment transaction: ${event.transactionHash}`); - logger.info(`Block number: ${event.blockNumber}`); - } - } - - return status; - } catch (error) { - throw new Error( - `Failed to query Portal contract: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -async function watchIntentStatus( - intentHash: Hex, - chain: ChainConfig, - options: StatusCommandOptions -): Promise { - const POLL_INTERVAL = 10_000; // 30 seconds - - if (!options.json) { - logger.title(`👀 Watching Intent Status`); - logger.info(`Polling every 10 seconds... (Press Ctrl+C to stop)`); - logger.info(''); - } - - let lastStatus: IntentStatus | null = null; - - while (true) { - try { - const status = await getIntentStatus(intentHash, chain, options.verbose); - - // Only display if status changed or it's the first check - if (!lastStatus || status.isFulfilled !== lastStatus.isFulfilled) { - if (options.json) { - logger.log( - JSON.stringify( - { - timestamp: new Date().toISOString(), - ...status, - }, - null, - 2 - ) - ); - } else { - if (lastStatus) { - logger.info(`Status changed at ${new Date().toLocaleTimeString()}`); - } - displayStatus(status, options); - - if (status.isFulfilled) { - logger.succeed('Intent fulfilled! Stopping watch...'); - break; - } - } - lastStatus = status; - } - - if (!options.json && !status.isFulfilled) { - process.stdout.write( - `\rLast checked: ${new Date().toLocaleTimeString()} - Status: Pending...` - ); - } - - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); - } catch (error) { - if (options.json) { - logger.log( - JSON.stringify( - { - timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), - }, - null, - 2 - ) - ); - } else { - logger.error( - `Error during watch: ${error instanceof Error ? error.message : String(error)}` - ); - } - - // Wait before retrying - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); - } - } -} - -function displayStatus(status: IntentStatus, options: StatusCommandOptions): void { - if (options.json) { - logger.log( - JSON.stringify( - status, - (key, value) => { - // Convert BigInt to string for JSON serialization - return typeof value === 'bigint' ? value.toString() : value; - }, - 2 - ) - ); - return; - } - - // Human-readable display - const statusText = status.isFulfilled ? chalk.green('✅ Fulfilled') : chalk.yellow('⏳ Pending'); - - logger.info(`Status: ${statusText}`); - - if (status.isFulfilled && status.claimant) { - logger.info(`Solver (Claimant): ${status.claimant}`); - - if (status.transactionHash) { - logger.info(`Fulfillment Transaction: ${status.transactionHash}`); - } - - if (status.blockNumber) { - logger.info(`Block Number: ${status.blockNumber.toString()}`); - } - - if (status.timestamp) { - logger.info(`Fulfilled At: ${status.timestamp.toLocaleString()}`); - } - } else if (!status.isFulfilled) { - logger.info('The intent has not been fulfilled yet.'); - logger.info('Solvers are still working to execute this intent.'); - } -} diff --git a/src/commons/utils/buffer.ts b/src/commons/utils/buffer.ts index 63e3b0d..0733adc 100644 --- a/src/commons/utils/buffer.ts +++ b/src/commons/utils/buffer.ts @@ -1,3 +1,3 @@ import { Hex } from 'viem'; -export const toBuffer = (hex: Hex) => Buffer.from(hex.slice(2), 'hex'); +export const toBuffer = (hex: Hex): Buffer => Buffer.from(hex.slice(2), 'hex'); diff --git a/src/commons/utils/converter.ts b/src/commons/utils/converter.ts index a18d62a..98f5d4f 100644 --- a/src/commons/utils/converter.ts +++ b/src/commons/utils/converter.ts @@ -2,7 +2,7 @@ import { PublicKey } from '@solana/web3.js'; import { Hex } from 'viem'; import { toBuffer } from '@/commons/utils/buffer'; -import { SvmAddress } from '@/core/types/blockchain-addresses'; +import { SvmAddress } from '@/shared/types'; export function addressToBytes32(address: string): number[] { // Convert Solana address or hex address to 32-byte array diff --git a/src/commons/utils/instruments.ts b/src/commons/utils/instruments.ts index f7ecee0..e8a6e62 100644 --- a/src/commons/utils/instruments.ts +++ b/src/commons/utils/instruments.ts @@ -1,7 +1,7 @@ import { BN, web3 } from '@coral-xyz/anchor'; -import { Intent } from '@/core/interfaces/intent'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { Intent } from '@/shared/types'; import { PortalIdlTypes } from '../types/portal-idl.type'; import * as PortalIdlCoder from '../types/portal-idl-coder.type'; diff --git a/src/commons/utils/portal-borsh-coder.ts b/src/commons/utils/portal-borsh-coder.ts index 02551b7..53a1b05 100644 --- a/src/commons/utils/portal-borsh-coder.ts +++ b/src/commons/utils/portal-borsh-coder.ts @@ -2,7 +2,7 @@ import { BorshCoder, Idl } from '@coral-xyz/anchor'; import { getPortalIdlByNetwork, Network } from '@/commons/idls/portal.idl'; -export function getPortalBorshCoder(network: Network) { +export function getPortalBorshCoder(network: Network): BorshCoder { const idl = getPortalIdlByNetwork(network); return new BorshCoder(idl as unknown as Idl); } diff --git a/src/commons/utils/portal-hash.utils.ts b/src/commons/utils/portal-hash.utils.ts index 2c15ce8..645e5f8 100644 --- a/src/commons/utils/portal-hash.utils.ts +++ b/src/commons/utils/portal-hash.utils.ts @@ -7,26 +7,29 @@ import { encodePacked, Hex, keccak256 } from 'viem'; -import { Intent } from '@/core/interfaces/intent'; -import { ChainTypeDetector } from '@/core/utils/chain-detector'; -import { PortalEncoder } from '@/core/utils/portal-encoder'; +import { PortalEncoder } from '@/blockchain/utils/portal-encoder'; +import { ChainType, Intent } from '@/shared/types'; + +const TVM_CHAIN_IDS = new Set([728126428, 2494104990]); +const SVM_CHAIN_IDS = new Set([1399811149, 1399811150, 1399811151]); + +function detectChainType(chainId: bigint): ChainType { + const id = Number(chainId); + if (TVM_CHAIN_IDS.has(id)) return ChainType.TVM; + if (SVM_CHAIN_IDS.has(id)) return ChainType.SVM; + return ChainType.EVM; +} export class PortalHashUtils { static getIntentHash(intent: Intent): { intentHash: Hex; routeHash: Hex; rewardHash: Hex } { const routeHash = PortalHashUtils.computeRouteHash(intent.route, intent.destination); const rewardHash = PortalHashUtils.computeRewardHash(intent.reward, intent.sourceChainId); - // Compute the intent hash using encodePacked - // intentHash = keccak256(abi.encodePacked(destination, routeHash, rewardHash)) const intentHash = keccak256( encodePacked(['uint64', 'bytes32', 'bytes32'], [intent.destination, routeHash, rewardHash]) ); - return { - intentHash, - routeHash, - rewardHash, - }; + return { intentHash, routeHash, rewardHash }; } static getIntentHashFromReward( @@ -38,43 +41,21 @@ export class PortalHashUtils { const routeHash = keccak256(encodedRoute); const rewardHash = PortalHashUtils.computeRewardHash(reward, source); - // Compute the intent hash using encodePacked - // intentHash = keccak256(abi.encodePacked(destination, routeHash, rewardHash)) const intentHash = keccak256( encodePacked(['uint64', 'bytes32', 'bytes32'], [destination, routeHash, rewardHash]) ); - return { - intentHash, - routeHash, - rewardHash, - }; + return { intentHash, routeHash, rewardHash }; } - /** - * Computes route hash using source chain encoding - * Accepts both Intent route (with UniversalAddress) and EVMIntent route - * - * @param route - Route data structure - * @param destination - Destination chain id - * @returns Route hash as Hex - */ static computeRouteHash(route: Intent['route'], destination: bigint): Hex { - const chainType = ChainTypeDetector.detect(destination); + const chainType = detectChainType(destination); const routeEncoded = PortalEncoder.encode(route, chainType); return keccak256(routeEncoded); } - /** - * Computes reward hash using source chain encoding - * Accepts both Intent reward (with UniversalAddress) - * - * @param reward - Reward data structure - * @param sourceChainId - Source chain ID to determine encoding type - * @returns Reward hash as Hex - */ static computeRewardHash(reward: Intent['reward'], sourceChainId: bigint): Hex { - const chainType = ChainTypeDetector.detect(sourceChainId); + const chainType = detectChainType(sourceChainId); const rewardEncoded = PortalEncoder.encode(reward, chainType); return keccak256(rewardEncoded); } diff --git a/src/commons/utils/tvm-utils.ts b/src/commons/utils/tvm-utils.ts index 4256e5e..af52bd8 100644 --- a/src/commons/utils/tvm-utils.ts +++ b/src/commons/utils/tvm-utils.ts @@ -1,6 +1,6 @@ import { TronWeb } from 'tronweb'; -import { EvmAddress, TronAddress } from '@/core/types/blockchain-addresses'; +import { EvmAddress, TronAddress } from '@/shared/types'; /** * Utility service for TVM-specific operations like address conversions diff --git a/src/config/chains.ts b/src/config/chains.ts deleted file mode 100644 index 451109d..0000000 --- a/src/config/chains.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Chain Configuration - */ - -import { arbitrum, bsc, hyperEvm, mainnet, polygon, ronin, sonic } from 'viem/chains'; - -import { ChainType } from '@/core/interfaces/intent'; -import { BlockchainAddress } from '@/core/types/blockchain-addresses'; -import { UniversalAddress } from '@/core/types/universal-address'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; -import { logger } from '@/utils/logger'; - -export interface ChainConfig { - id: bigint; - name: string; - env: 'production' | 'development'; - type: ChainType; - rpcUrl: string; - portalAddress?: UniversalAddress; - proverAddress?: UniversalAddress; - nativeCurrency: { - name: string; - symbol: string; - decimals: number; - }; -} - -// Default chain configurations -const chains: Record = { - // EVM Chains - ethereum: { - id: 1n, - name: 'Ethereum', - type: ChainType.EVM, - env: 'production', - rpcUrl: mainnet.rpcUrls.default.http[0], - nativeCurrency: mainnet.nativeCurrency, - }, - optimism: { - id: 10n, - name: 'Optimism', - type: ChainType.EVM, - env: 'production', - rpcUrl: 'https://mainnet.optimism.io', - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - bsc: { - id: BigInt(bsc.id), - name: bsc.name, - type: ChainType.EVM, - env: 'production', - rpcUrl: bsc.rpcUrls.default.http[0], - nativeCurrency: bsc.nativeCurrency, - }, - base: { - id: 8453n, - name: 'Base', - type: ChainType.EVM, - env: 'production', - rpcUrl: 'https://mainnet.base.org', - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - arbitrum: { - id: BigInt(arbitrum.id), - name: arbitrum.name, - type: ChainType.EVM, - env: 'production', - rpcUrl: arbitrum.rpcUrls.default.http[0], - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - polygon: { - id: BigInt(polygon.id), - name: polygon.name, - type: ChainType.EVM, - env: 'production', - rpcUrl: polygon.rpcUrls.default.http[0], - nativeCurrency: polygon.nativeCurrency, - }, - ronin: { - id: BigInt(ronin.id), - name: ronin.name, - type: ChainType.EVM, - env: 'production', - rpcUrl: ronin.rpcUrls.default.http[0], - nativeCurrency: ronin.nativeCurrency, - }, - - sonic: { - id: BigInt(sonic.id), - name: sonic.name, - type: ChainType.EVM, - env: 'production', - rpcUrl: sonic.rpcUrls.default.http[0], - nativeCurrency: sonic.nativeCurrency, - }, - - hyperevm: { - id: BigInt(hyperEvm.id), - name: hyperEvm.name, - type: ChainType.EVM, - env: 'production', - rpcUrl: hyperEvm.rpcUrls.default.http[0], - nativeCurrency: hyperEvm.nativeCurrency, - }, - - // Testnet Chains - 'base-sepolia': { - id: 84532n, - name: 'Base Sepolia', - type: ChainType.EVM, - env: 'development', - rpcUrl: 'https://sepolia.base.org', - portalAddress: AddressNormalizer.normalize( - '0x06EFdb68dbF245ECb49E3aE10Cd0f893B674443c', - ChainType.EVM - ), - proverAddress: AddressNormalizer.normalize( - '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', - ChainType.EVM - ), - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - 'optimism-sepolia': { - id: 11155420n, - name: 'Optimism Sepolia', - type: ChainType.EVM, - env: 'development', - rpcUrl: 'https://sepolia.optimism.io', - portalAddress: AddressNormalizer.normalize( - '0x06EFdb68dbF245ECb49E3aE10Cd0f893B674443c', - ChainType.EVM - ), - proverAddress: AddressNormalizer.normalize( - '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', - ChainType.EVM - ), - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - 'plasma-testnet': { - id: 9746n, - name: 'Plasma Testnet', - type: ChainType.EVM, - env: 'development', - rpcUrl: 'https://rpc.testnet.plasm.technology', - portalAddress: AddressNormalizer.normalize( - '0x06EFdb68dbF245ECb49E3aE10Cd0f893B674443c', - ChainType.EVM - ), - proverAddress: AddressNormalizer.normalize( - '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', - ChainType.EVM - ), - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - sepolia: { - id: 11155111n, - name: 'Sepolia', - type: ChainType.EVM, - env: 'development', - rpcUrl: 'https://rpc.sepolia.org', - portalAddress: AddressNormalizer.normalize( - '0x06EFdb68dbF245ECb49E3aE10Cd0f893B674443c', - ChainType.EVM - ), - proverAddress: AddressNormalizer.normalize( - '0x9523b6c0caac8122dbd5dd1c1d336ceba637038d', - ChainType.EVM - ), - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18, - }, - }, - - // TVM Chains - tron: { - id: 728126428n, - name: 'Tron', - type: ChainType.TVM, - env: 'production', - rpcUrl: 'https://api.trongrid.io', - nativeCurrency: { - name: 'Tron', - symbol: 'TRX', - decimals: 6, - }, - }, - 'tron-shasta': { - id: 2494104990n, - name: 'Tron Shasta', - type: ChainType.TVM, - env: 'development', - rpcUrl: 'https://api.shasta.trongrid.io', - nativeCurrency: { - name: 'Tron', - symbol: 'TRX', - decimals: 6, - }, - }, - - // SVM Chains - solana: { - id: 1399811149n, - name: 'Solana', - type: ChainType.SVM, - env: 'production', - rpcUrl: 'https://api.mainnet-beta.solana.com', - nativeCurrency: { - name: 'Solana', - symbol: 'SOL', - decimals: 9, - }, - }, - - 'solana-devnet': { - id: 1399811150n, // Solana devnet chain ID (from onchain) - name: 'Solana Devnet', - type: ChainType.SVM, - env: 'development', - rpcUrl: 'https://api.devnet.solana.com', - nativeCurrency: { - name: 'Solana', - symbol: 'SOL', - decimals: 9, - }, - }, -}; - -const ENV = process.env.NODE_CHAINS_ENV || 'production'; -export const CHAIN_CONFIGS: typeof chains = Object.fromEntries( - Object.entries(chains).filter(([, chain]) => chain.env === ENV) -); - -// Helper function to get chain by ID -export function getChainById(chainId: bigint): ChainConfig | undefined { - return Object.values(CHAIN_CONFIGS).find(chain => chain.id.toString() === chainId.toString()); -} - -// Helper function to get chain by name -export function getChainByName(name: string): ChainConfig | undefined { - return CHAIN_CONFIGS[name.toLowerCase()]; -} - -// Helper function to list all supported chains -export function listChains(): ChainConfig[] { - return Object.values(CHAIN_CONFIGS); -} - -// Update Portal address from environment if available -export function updatePortalAddresses(env: Record) { - const addressMappings: Record = { - PORTAL_ADDRESS_ETH: 'ethereum', - PORTAL_ADDRESS_OPTIMISM: 'optimism', - PORTAL_ADDRESS_BASE: 'base', - PORTAL_ADDRESS_TRON: 'tron', - PORTAL_ADDRESS_SOLANA: 'solana', - }; - - for (const [envKey, chainKey] of Object.entries(addressMappings)) { - const address = env[envKey]; - if (address && CHAIN_CONFIGS[chainKey]) { - try { - CHAIN_CONFIGS[chainKey].portalAddress = AddressNormalizer.normalize( - address as BlockchainAddress, - CHAIN_CONFIGS[chainKey].type - ); - } catch (error) { - logger.warning( - `Failed to set portal address for ${chainKey}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } -} diff --git a/src/config/config.module.ts b/src/config/config.module.ts new file mode 100644 index 0000000..8a3622e --- /dev/null +++ b/src/config/config.module.ts @@ -0,0 +1,32 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; + +import { EnvSchema } from './validation/env.schema'; +import { ConfigService } from './config.service'; + +@Global() +@Module({ + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + validate: config => { + const result = EnvSchema.safeParse(config); + if (!result.success) { + const lines = result.error.issues.map( + issue => ` ${issue.path.join('.')}: ${issue.message}` + ); + console.error( + '\nConfiguration error: invalid or missing environment variables\n\n' + + lines.join('\n') + + '\n\nCopy .env.example to .env and fill in the required values.\n' + ); + process.exit(1); + } + return result.data; + }, + }), + ], + providers: [ConfigService], + exports: [ConfigService], +}) +export class ConfigModule {} diff --git a/src/config/config.service.ts b/src/config/config.service.ts new file mode 100644 index 0000000..d723cc3 --- /dev/null +++ b/src/config/config.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService as NestConfigService } from '@nestjs/config'; + +import { Hex } from 'viem'; + +import { ChainType } from '@/shared/types'; + +@Injectable() +export class ConfigService { + constructor(private readonly config: NestConfigService) {} + + getEvmPrivateKey(): Hex | undefined { + return this.config.get('EVM_PRIVATE_KEY'); + } + + getTvmPrivateKey(): string | undefined { + return this.config.get('TVM_PRIVATE_KEY'); + } + + getSvmPrivateKey(): string | undefined { + return this.config.get('SVM_PRIVATE_KEY'); + } + + getKeyForChainType(chainType: ChainType): string | undefined { + switch (chainType) { + case ChainType.EVM: + return this.getEvmPrivateKey(); + case ChainType.TVM: + return this.getTvmPrivateKey(); + case ChainType.SVM: + return this.getSvmPrivateKey(); + } + } + + getRpcUrl(chainType: ChainType, variant: 'primary' | 'fallback' = 'primary'): string | undefined { + const map: Record> = { + [ChainType.EVM]: { + primary: '', + fallback: '', + }, + [ChainType.TVM]: { + primary: this.config.get('TVM_RPC_URL') ?? 'https://api.trongrid.io', + fallback: this.config.get('TVM_RPC_URL_2') ?? 'https://tron.publicnode.com', + }, + [ChainType.SVM]: { + primary: this.config.get('SVM_RPC_URL') ?? 'https://api.mainnet-beta.solana.com', + fallback: this.config.get('SVM_RPC_URL_2') ?? 'https://solana.publicnode.com', + }, + }; + return map[chainType][variant] || undefined; + } + + getQuoteEndpoint(): { url: string; type: 'solver-v2' | 'preprod' | 'production' } { + const solverUrl = this.config.get('SOLVER_URL'); + if (solverUrl) { + return { url: `${solverUrl}/api/v2/quote/reverse`, type: 'solver-v2' }; + } + if (this.config.get('QUOTES_API_URL') || this.config.get('QUOTES_PREPROD')) { + return { url: 'https://quotes-preprod.eco.com/api/v3/quotes/single', type: 'preprod' }; + } + return { url: 'https://quotes.eco.com/api/v3/quotes/single', type: 'production' }; + } + + getDeadlineOffsetSeconds(): number { + return this.config.get('DEADLINE_OFFSET_SECONDS') ?? 9000; + } + + getDappId(): string { + return this.config.get('DAPP_ID') ?? 'eco-routes-cli'; + } + + getChainsEnv(): 'production' | 'development' { + return this.config.get<'production' | 'development'>('NODE_CHAINS_ENV') ?? 'production'; + } + + isDebug(): boolean { + return !!this.config.get('DEBUG'); + } +} diff --git a/src/config/env.ts b/src/config/env.ts deleted file mode 100644 index e3c4667..0000000 --- a/src/config/env.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Environment Configuration - */ - -import * as dotenv from 'dotenv'; -import { Hex } from 'viem'; - -// Load environment variables -dotenv.config(); - -export interface EnvConfig { - evmPrivateKey?: Hex; - tvmPrivateKey?: string; - svmPrivateKey?: string; - evmRpcUrl?: string; - tvmRpcUrl?: string; - svmRpcUrl?: string; - solverUrl?: string; -} - -export function loadEnvConfig(): EnvConfig { - return { - evmPrivateKey: process.env.EVM_PRIVATE_KEY as Hex | undefined, - tvmPrivateKey: process.env.TVM_PRIVATE_KEY, - svmPrivateKey: process.env.SVM_PRIVATE_KEY, - evmRpcUrl: process.env.EVM_RPC_URL, - tvmRpcUrl: process.env.TVM_RPC_URL || 'https://api.trongrid.io', - svmRpcUrl: process.env.SVM_RPC_URL || 'https://api.mainnet-beta.solana.com', - solverUrl: process.env.SOLVER_URL, - }; -} diff --git a/src/config/tokens.config.ts b/src/config/tokens.config.ts new file mode 100644 index 0000000..de7f3e8 --- /dev/null +++ b/src/config/tokens.config.ts @@ -0,0 +1,250 @@ +/** + * Token Configuration + */ + +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { EvmAddress, SvmAddress, TronAddress, UniversalAddress } from '@/shared/types'; + +/** Describes a cross-chain token and its deployed contract addresses. */ +export interface TokenConfig { + /** Ticker symbol, e.g. `"USDC"`, `"USDT"`. */ + symbol: string; + /** Human-readable name, e.g. `"USD Coin"`. */ + name: string; + /** + * Number of decimal places for the smallest unit. + * Used to convert between human-readable amounts and on-chain integers + * (e.g. `6` for USDC: `1 USDC = 1_000_000` base units). + */ + decimals: number; + /** + * Map of chain ID (as decimal string) to Universal-format token address. + * + * String keys are required because `bigint` cannot be a JavaScript object key. + * Lookup pattern: `token.addresses[chainId.toString()]` + * + * @example `{ "8453": "0x000...abc", "1": "0x000...def" }` + */ + addresses: Record; +} + +// Common token configurations +export const TOKEN_CONFIGS: Record = { + USDC: { + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + addresses: { + '1': AddressNormalizer.normalizeEvm( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as EvmAddress + ), // Ethereum + '10': AddressNormalizer.normalizeEvm( + '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85' as EvmAddress + ), // Optimism + '8453': AddressNormalizer.normalizeEvm( + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as EvmAddress + ), // Base + '137': AddressNormalizer.normalizeEvm( + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' as EvmAddress + ), // Polygon + '999': AddressNormalizer.normalizeEvm( + '0xb88339CB7199b77E23DB6E890353E22632Ba630f' as EvmAddress + ), // Hyperevm + '2020': AddressNormalizer.normalizeEvm( + '0x0b7007c13325c48911f73a2dad5fa5dcbf808adc' as EvmAddress + ), // Runin + '42161': AddressNormalizer.normalizeEvm( + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as EvmAddress + ), // Arbitrum + '146': AddressNormalizer.normalizeEvm( + '0x29219dd400f2bf60e5a23d13be72b486d4038894' as EvmAddress + ), // Sonic + '84532': AddressNormalizer.normalizeEvm( + '0x036cbd53842c5426634e7929541ec2318f3dcf7e' as EvmAddress + ), // Base Sepolia + '11155420': AddressNormalizer.normalizeEvm( + '0x5fd84259d66Cd46123540766Be93DFE6D43130D7' as EvmAddress + ), // Optimism Sepolia + '9746': AddressNormalizer.normalizeEvm( + '0x107d0b0428741b37331138040F793aF171682603' as EvmAddress + ), // Plasma Testnet + '11155111': AddressNormalizer.normalizeEvm( + '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' as EvmAddress + ), // Sepolia + '1399811149': AddressNormalizer.normalizeSvm( + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as SvmAddress + ), + '1399811150': AddressNormalizer.normalizeSvm( + '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU' as SvmAddress + ), + // Add more as needed + }, + }, + USDT: { + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + addresses: { + '1': AddressNormalizer.normalizeEvm( + '0xdAC17F958D2ee523a2206206994597C13D831ec7' as EvmAddress + ), // Ethereum + '10': AddressNormalizer.normalizeEvm( + '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58' as EvmAddress + ), // Optimism + '999': AddressNormalizer.normalizeEvm( + '0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb' as EvmAddress + ), // Hyperevm + '8453': AddressNormalizer.normalizeEvm( + '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2' as EvmAddress + ), // Base + '728126428': AddressNormalizer.normalizeTvm( + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' as TronAddress + ), // Tron + '2494104990': AddressNormalizer.normalizeTvm( + 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs' as TronAddress + ), // Tron Shasta + '1399811149': AddressNormalizer.normalizeSvm( + 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' as SvmAddress + ), + // Add more as needed + }, + }, + bUSDC: { + symbol: 'bUSDC', + name: 'Binance USDC', + decimals: 18, + addresses: { + '56': AddressNormalizer.normalizeEvm( + '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d' as EvmAddress + ), // BNB Smart Chain + }, + }, + bUSDT: { + symbol: 'bUSDT', + name: 'Binance USDT', + decimals: 18, + addresses: { + '56': AddressNormalizer.normalizeEvm( + '0x55d398326f99059fF775485246999027B3197955' as EvmAddress + ), // BNB Smart Chain + }, + }, + ETH: { + symbol: 'ETH', + name: 'Ether', + decimals: 18, + addresses: { + // ETH-native chains → zero address (no contract; native currency sentinel) + '1': AddressNormalizer.normalizeEvm( + '0x0000000000000000000000000000000000000000' as EvmAddress + ), // Ethereum + '10': AddressNormalizer.normalizeEvm( + '0x0000000000000000000000000000000000000000' as EvmAddress + ), // Optimism + '8453': AddressNormalizer.normalizeEvm( + '0x0000000000000000000000000000000000000000' as EvmAddress + ), // Base + '42161': AddressNormalizer.normalizeEvm( + '0x0000000000000000000000000000000000000000' as EvmAddress + ), // Arbitrum + // ETH testnets → zero address + '11155111': AddressNormalizer.normalizeEvm( + '0x0000000000000000000000000000000000000000' as EvmAddress + ), // Sepolia + '84532': AddressNormalizer.normalizeEvm( + '0x0000000000000000000000000000000000000000' as EvmAddress + ), // Base Sepolia + '11155420': AddressNormalizer.normalizeEvm( + '0x0000000000000000000000000000000000000000' as EvmAddress + ), // Optimism Sepolia + }, + }, + WETH: { + symbol: 'WETH', + name: 'Wrap Ether', + decimals: 18, + addresses: { + // ETH-native chains → zero address (no contract; native currency sentinel) + '1': AddressNormalizer.normalizeEvm( + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' as EvmAddress + ), // Ethereum + '8453': AddressNormalizer.normalizeEvm( + '0x4200000000000000000000000000000000000006' as EvmAddress + ), // Base + '2020': AddressNormalizer.normalizeEvm( + '0xc99a6a985ed2cac1ef41640596c5a5f9f4e19ef5' as EvmAddress + ), // Ronin WETH + }, + }, +}; + +/** + * Looks up a token configuration by ticker symbol (case-sensitive). + * + * @param symbol - Ticker symbol, e.g. `"USDC"`. + * @returns The matching {@link TokenConfig}, or `undefined` if not found. + * + * @example + * ```ts + * const usdc = getTokenBySymbol('USDC'); + * // usdc?.decimals === 6 + * ``` + */ +export function getTokenBySymbol(symbol: string): TokenConfig | undefined { + return TOKEN_CONFIGS[symbol]; +} + +/** + * Returns the Universal-format address of a token on a specific chain. + * + * @param symbol - Ticker symbol, e.g. `"USDC"`. + * @param chainId - The target chain ID. + * @returns The Universal-format token address, or `undefined` if the token + * does not have a deployment on the given chain. + * + * @example + * ```ts + * const addr = getTokenAddress('USDC', 8453n); // Base mainnet USDC + * ``` + */ +export function getTokenAddress(symbol: string, chainId: bigint): UniversalAddress | undefined { + const token = getTokenBySymbol(symbol); + if (!token) return undefined; + + // Use chainId as string for lookup + return token.addresses[chainId.toString()]; +} + +/** + * Returns all token configurations registered in {@link TOKEN_CONFIGS}. + * + * @returns An array of every {@link TokenConfig}. + * + * @example + * ```ts + * listTokens().forEach(t => console.log(t.symbol)); + * ``` + */ +export function listTokens(): TokenConfig[] { + return Object.values(TOKEN_CONFIGS); +} + +/** + * Registers a custom token in the global {@link TOKEN_CONFIGS} map. + * + * The symbol is normalised to uppercase before insertion, so `"usdc"` and + * `"USDC"` resolve to the same key. + * + * @param config - The token configuration to register. + * + * @example + * ```ts + * addCustomToken({ symbol: 'MYTOKEN', name: 'My Token', decimals: 18, addresses: {} }); + * ``` + */ +export function addCustomToken(config: TokenConfig): void { + TOKEN_CONFIGS[config.symbol.toUpperCase()] = config; +} + +// Re-export TOKENS as alias for backward compatibility with plan references +export const TOKENS = TOKEN_CONFIGS; diff --git a/src/config/tokens.ts b/src/config/tokens.ts deleted file mode 100644 index 286aba8..0000000 --- a/src/config/tokens.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Token Configuration - */ - -import { ChainType } from '@/core/interfaces/intent'; -import { SvmAddress } from '@/core/types/blockchain-addresses'; -import { UniversalAddress } from '@/core/types/universal-address'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; - -export interface TokenConfig { - symbol: string; - name: string; - decimals: number; - addresses: Record; // chainId (as string) -> address -} - -// Common token configurations -export const TOKEN_CONFIGS: Record = { - USDC: { - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - addresses: { - '1': AddressNormalizer.normalize('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', ChainType.EVM), // Ethereum - '10': AddressNormalizer.normalize( - '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', - ChainType.EVM - ), // Optimism - '8453': AddressNormalizer.normalize( - '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - ChainType.EVM - ), // Base - '137': AddressNormalizer.normalize( - '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', - ChainType.EVM - ), // Polygon - '999': AddressNormalizer.normalize( - '0xb88339CB7199b77E23DB6E890353E22632Ba630f', - ChainType.EVM - ), // Hyperevm - '2020': AddressNormalizer.normalize( - '0x0b7007c13325c48911f73a2dad5fa5dcbf808adc', - ChainType.EVM - ), // Runin - '42161': AddressNormalizer.normalize( - '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', - ChainType.EVM - ), // Base - '146': AddressNormalizer.normalize( - '0x29219dd400f2bf60e5a23d13be72b486d4038894', - ChainType.EVM - ), // Sonic - '84532': AddressNormalizer.normalize( - '0x036cbd53842c5426634e7929541ec2318f3dcf7e', - ChainType.EVM - ), // Base Sepolia - '11155420': AddressNormalizer.normalize( - '0x5fd84259d66Cd46123540766Be93DFE6D43130D7', - ChainType.EVM - ), // Optimism Sepolia - '9746': AddressNormalizer.normalize( - '0x107d0b0428741b37331138040F793aF171682603', - ChainType.EVM - ), // Plasma Testnet - '11155111': AddressNormalizer.normalize( - '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', - ChainType.EVM - ), // Sepolia - '1399811149': AddressNormalizer.normalizeSvm( - 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as SvmAddress - ), - '1399811150': AddressNormalizer.normalizeSvm( - '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU' as SvmAddress - ), - // Add more as needed - }, - }, - USDT: { - symbol: 'USDT', - name: 'Tether USD', - decimals: 6, - addresses: { - '1': AddressNormalizer.normalize('0xdAC17F958D2ee523a2206206994597C13D831ec7', ChainType.EVM), // Ethereum - '10': AddressNormalizer.normalize( - '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', - ChainType.EVM - ), // Optimism - '999': AddressNormalizer.normalize( - '0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb', - ChainType.EVM - ), // Hyperevm - '8453': AddressNormalizer.normalize( - '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', - ChainType.EVM - ), // Base - '728126428': AddressNormalizer.normalize('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', ChainType.TVM), // Tron - '2494104990': AddressNormalizer.normalize( - 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', - ChainType.TVM - ), // Tron Shasta - '1399811149': AddressNormalizer.normalizeSvm( - 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' as SvmAddress - ), - // Add more as needed - }, - }, - bUSDC: { - symbol: 'bUSDC', - name: 'Binance USDC', - decimals: 18, - addresses: { - '56': AddressNormalizer.normalize( - '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', - ChainType.EVM - ), // BNB Smart Chain - }, - }, - bUSDT: { - symbol: 'bUSDT', - name: 'Binance USDT', - decimals: 18, - addresses: { - '56': AddressNormalizer.normalize( - '0x55d398326f99059fF775485246999027B3197955', - ChainType.EVM - ), // BNB Smart Chain - }, - }, -}; - -// Helper function to get token by symbol -export function getTokenBySymbol(symbol: string): TokenConfig | undefined { - return TOKEN_CONFIGS[symbol]; -} - -// Helper function to get token address on a specific chain -export function getTokenAddress(symbol: string, chainId: bigint): UniversalAddress | undefined { - const token = getTokenBySymbol(symbol); - if (!token) return undefined; - - // Use chainId as string for lookup - return token.addresses[chainId.toString()]; -} - -// Helper function to list all tokens -export function listTokens(): TokenConfig[] { - return Object.values(TOKEN_CONFIGS); -} - -// Helper function to add a custom token -export function addCustomToken(config: TokenConfig): void { - TOKEN_CONFIGS[config.symbol.toUpperCase()] = config; -} diff --git a/src/config/validation/env.schema.ts b/src/config/validation/env.schema.ts new file mode 100644 index 0000000..0480ac5 --- /dev/null +++ b/src/config/validation/env.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const EnvSchema = z.object({ + EVM_PRIVATE_KEY: z + .string() + .regex(/^0x[a-fA-F0-9]{64}$/) + .optional(), + TVM_PRIVATE_KEY: z + .string() + .regex(/^[a-fA-F0-9]{64}$/) + .optional(), + SVM_PRIVATE_KEY: z.string().min(1).optional(), + + TVM_RPC_URL: z.string().url().default('https://api.trongrid.io'), + TVM_RPC_URL_2: z.string().url().default('https://tron.publicnode.com'), + SVM_RPC_URL: z.string().url().default('https://api.mainnet-beta.solana.com'), + SVM_RPC_URL_2: z.string().url().default('https://solana.publicnode.com'), + + SOLVER_URL: z.string().url().optional(), + QUOTES_API_URL: z.string().optional(), + QUOTES_PREPROD: z.string().optional(), + + NODE_CHAINS_ENV: z.enum(['production', 'development']).default('production'), + DEBUG: z.string().optional(), + + DAPP_ID: z.string().default('eco-routes-cli'), + DEADLINE_OFFSET_SECONDS: z.coerce.number().positive().default(9000), +}); + +export type EnvConfig = z.infer; diff --git a/src/core/utils/address-normalizer.ts b/src/core/utils/address-normalizer.ts deleted file mode 100644 index 8c3d82b..0000000 --- a/src/core/utils/address-normalizer.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Address Normalization Utility - * - * Provides utilities for converting between chain-native address formats and the normalized - * UniversalAddress format used throughout the Routes CLI system. This enables cross-chain - * compatibility by providing a unified 32-byte address representation. - * - * Supported blockchain types: - * - EVM: Ethereum Virtual Machine chains (Ethereum, Optimism, Base, etc.) - * - TVM: Tron Virtual Machine (Tron blockchain) - * - SVM: Solana Virtual Machine (Solana blockchain) - * - * @example - * ```typescript - * // Normalize an EVM address - * const evmAddress = '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b'; - * const universal = AddressNormalizer.normalize(evmAddress, ChainType.EVM); - * - * // Denormalize back to EVM format - * const original = AddressNormalizer.denormalize(universal, ChainType.EVM); - * ``` - */ - -import { PublicKey } from '@solana/web3.js'; -import { TronWeb } from 'tronweb'; -import { getAddress, isAddress as isViemAddress } from 'viem'; - -import { getErrorMessage } from '@/commons/utils/error-handler'; -import { ChainType } from '@/core/interfaces/intent'; -import { - BlockchainAddress, - EvmAddress, - SvmAddress, - TronAddress, -} from '@/core/types/blockchain-addresses'; -import { padTo32Bytes, UniversalAddress, unpadFrom32Bytes } from '@/core/types/universal-address'; - -export class AddressNormalizer { - /** - * Normalizes a chain-native address to UniversalAddress format. - * - * This method converts addresses from their native blockchain format to a standardized - * 32-byte hexadecimal representation that can be used across all supported chains. - * - * @param address - The address in chain-native format (EVM hex, Tron base58, or Solana base58) - * @param chainType - The blockchain type indicating the source format - * @returns Normalized UniversalAddress (0x prefix + 64 hex characters) - * @throws {Error} When the chain type is unsupported or address format is invalid - * - * @example - * ```typescript - * // EVM address - * const evmUniversal = AddressNormalizer.normalize( - * '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b', - * ChainType.EVM - * ); - * - * // Tron address - * const tronUniversal = AddressNormalizer.normalize( - * 'TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH', - * ChainType.TVM - * ); - * - * // Solana address - * const solanaUniversal = AddressNormalizer.normalize( - * 'So11111111111111111111111111111111111111112', - * ChainType.SVM - * ); - * ``` - */ - static normalize(address: BlockchainAddress, chainType: ChainType): UniversalAddress { - switch (chainType) { - case ChainType.EVM: - return this.normalizeEvm(address as EvmAddress); - case ChainType.TVM: - return this.normalizeTvm(address as TronAddress); - case ChainType.SVM: - return this.normalizeSvm(address as SvmAddress); - default: - throw new Error(`Unsupported chain type: ${chainType}`); - } - } - - /** - * Denormalizes a UniversalAddress to chain-native format. - * - * This method converts a standardized 32-byte UniversalAddress back to the native - * address format expected by the target blockchain. This is essential when making - * actual blockchain calls or displaying addresses to users. - * - * @param address - The normalized UniversalAddress to convert - * @param chainType - The target blockchain type for the output format - * @returns Address in the chain-native format (EVM hex, Tron base58, or Solana base58) - * @throws {Error} When the chain type is unsupported or address is invalid - * - * @example - * ```typescript - * const universal = '0x000000000000000000000000742d35cc6634c0532925a3b8d65c32c2b3f6de1b'; - * - * // Convert to EVM format - * const evmAddr = AddressNormalizer.denormalize(universal, ChainType.EVM); - * // Returns: '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b' - * - * // Convert to Tron format - * const tronAddr = AddressNormalizer.denormalize(universal, ChainType.TVM); - * // Returns: 'TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH' - * ``` - */ - static denormalize< - chainType extends ChainType, - Addr extends chainType extends ChainType.TVM - ? TronAddress - : chainType extends ChainType.EVM - ? EvmAddress - : chainType extends ChainType.SVM - ? SvmAddress - : never, - >(address: UniversalAddress, chainType: chainType): Addr { - switch (chainType) { - case ChainType.EVM: - return this.denormalizeToEvm(address) as Addr; - case ChainType.TVM: - return this.denormalizeToTvm(address) as Addr; - case ChainType.SVM: - return this.denormalizeToSvm(address) as Addr; - default: - throw new Error(`Unsupported chain type: ${chainType}`); - } - } - - /** - * Denormalizes a UniversalAddress to EVM (Ethereum) format. - * - * Converts a 32-byte universal address to a 20-byte EVM address with proper - * checksumming according to EIP-55 specification. - * - * @param address - The UniversalAddress to convert - * @returns EVM address in checksummed hex format (0x + 40 hex characters) - * @throws {Error} When the resulting address is invalid - * - * @example - * ```typescript - * const universal = '0x000000000000000000000000742d35cc6634c0532925a3b8d65c32c2b3f6de1b'; - * const evmAddr = AddressNormalizer.denormalizeToEvm(universal); - * // Returns: '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b' - * ``` - */ - static denormalizeToEvm(address: UniversalAddress): EvmAddress { - // Remove padding (the last 24 characters are padding for 20-byte EVM addresses) - const unpadded = unpadFrom32Bytes(address); - - // Take only the last 40 hex characters (20 bytes) - const cleanHex = unpadded.substring(2); // Remove 0x - const evmHex = cleanHex.length > 40 ? cleanHex.substring(cleanHex.length - 40) : cleanHex; - - // Validate and return checksum address - const evmAddress = '0x' + evmHex; - if (!isViemAddress(evmAddress)) { - throw new Error(`Invalid EVM address after denormalization: ${evmAddress}`); - } - - return getAddress(evmAddress); - } - - /** - * Denormalizes a UniversalAddress to TVM (Tron) format. - * - * Converts a 32-byte universal address to a Tron address in base58 format. - * Handles both hex and base58 representations internally and validates the result. - * - * @param address - The UniversalAddress to convert - * @returns Tron address in base58 format (starting with 'T') - * @throws {Error} When conversion fails or the resulting address is invalid - * - * @example - * ```typescript - * const universal = '0x41c4a8f8b915b8c0e6a5e6c8b2c4d3f2a8b9c7d6e5f4a3b2c1d0e9f8a7b6c5d4'; - * const tronAddr = AddressNormalizer.denormalizeToTvm(universal); - * // Returns: 'TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH' - * ``` - */ - static denormalizeToTvm(address: UniversalAddress): TronAddress { - try { - // Remove padding - const unpadded = unpadFrom32Bytes(address); - - // Remove 0x prefix - const hexAddress = unpadded.startsWith('0x41') - ? unpadded.substring(2) - : '41' + unpadded.substring(2); - - // Convert to base58 Tron address - const base58Address = TronWeb.address.fromHex(hexAddress); - - if (!TronWeb.isAddress(base58Address)) { - throw new Error(`Invalid Tron address after denormalization: ${base58Address}`); - } - - return base58Address as TronAddress; - } catch (error) { - throw new Error(`Failed to denormalize to TVM address: ${getErrorMessage(error)}`); - } - } - - /** - * Denormalizes a UniversalAddress to SVM (Solana) format. - * - * Converts a 32-byte universal address to a Solana address in base58 format. - * Solana addresses are naturally 32 bytes, so no padding removal is needed. - * - * @param address - The UniversalAddress to convert - * @returns Solana address in base58 format - * @throws {Error} When the address length is invalid or conversion fails - * - * @example - * ```typescript - * const universal = '0x11111111254fb6c44bAC0beD2854e76F90643097d395B1c8de5D3000000000000'; - * const solanaAddr = AddressNormalizer.denormalizeToSvm(universal); - * // Returns: 'So11111111111111111111111111111111111111112' - * ``` - */ - static denormalizeToSvm(address: UniversalAddress): SvmAddress { - try { - // Remove 0x prefix - const hex = address.startsWith('0x') ? address.slice(2) : address; - - // Convert hex to bytes (Solana addresses are 32 bytes, no unpadding needed) - const bytes = Buffer.from(hex, 'hex'); - - if (bytes.length !== 32) { - throw new Error(`Expected 32 bytes, got ${bytes.length}`); - } - - // Create PublicKey from bytes - const publicKey = new PublicKey(bytes); - - // Return base58 encoded address - return publicKey.toBase58() as SvmAddress; - } catch (error) { - throw new Error(`Failed to denormalize to SVM address: ${getErrorMessage(error)}`); - } - } - - /** - * Normalizes an EVM address to UniversalAddress format. - * - * Takes a 20-byte EVM address and pads it to 32 bytes for universal representation. - * The address is validated and checksummed before normalization. - * - * @param address - The EVM address to normalize (0x + 40 hex characters) - * @returns UniversalAddress with zero-padding (0x + 64 hex characters) - * @throws {Error} When the EVM address format is invalid - * - * @example - * ```typescript - * const evmAddr = '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b'; - * const universal = AddressNormalizer.normalizeEvm(evmAddr); - * // Returns: '0x000000000000000000000000742d35cc6634c0532925a3b8d65c32c2b3f6de1b' - * ``` - */ - static normalizeEvm(address: EvmAddress): UniversalAddress { - // Validate and checksum the address - if (!isViemAddress(address)) { - throw new Error(`Invalid EVM address: ${address}`); - } - - // Get checksummed address - const checksummed = getAddress(address); - - // Pad to 32 bytes (EVM addresses are 20 bytes, so we pad with 12 bytes of zeros) - return padTo32Bytes(checksummed) as UniversalAddress; - } - - /** - * Normalizes a Tron address to UniversalAddress format. - * - * Accepts Tron addresses in both base58 format (e.g., 'TLyqz...') and hex format. - * Validates the address and converts it to the universal 32-byte representation. - * - * @param address - The Tron address to normalize (base58 or hex format) - * @returns UniversalAddress with proper padding (0x + 64 hex characters) - * @throws {Error} When the Tron address format is invalid or conversion fails - * - * @example - * ```typescript - * // Base58 format - * const tronAddr = 'TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH'; - * const universal = AddressNormalizer.normalizeTvm(tronAddr); - * - * // Hex format also supported - * const hexAddr = '0x41c4a8f8b915b8c0e6a5e6c8b2c4d3f2a8b9c7d6'; - * const universal2 = AddressNormalizer.normalizeTvm(hexAddr); - * ``` - */ - static normalizeTvm(address: TronAddress): UniversalAddress { - try { - let hexAddress: string; - - // Check if it's already hex format - if (address.startsWith('0x')) { - const hexTronAddr = address.startsWith('0x41') ? address : '0x41' + address.substring(2); - - // Validate it's a proper Tron hex address - const base58 = TronWeb.address.fromHex(hexTronAddr.substring(2)); - if (!TronWeb.isAddress(base58)) { - throw new Error(`Invalid Tron hex address: ${address}`); - } - hexAddress = hexTronAddr.toLowerCase(); - } else { - // Assume it's base58 format - if (!TronWeb.isAddress(address)) { - throw new Error(`Invalid Tron base58 address: ${address}`); - } - // Convert to hex (Tron addresses are 21 bytes, first byte is 0x41) - const tronHex = TronWeb.address.toHex(address); - hexAddress = '0x' + tronHex.toLowerCase(); - } - - // Pad to 32 bytes - return padTo32Bytes(hexAddress) as UniversalAddress; - } catch (error) { - throw new Error(`Failed to normalize TVM address ${address}: ${getErrorMessage(error)}`); - } - } - - /** - * Normalizes a Solana address to UniversalAddress format. - * - * Accepts Solana addresses in base58 format or as PublicKey objects. - * Since Solana addresses are naturally 32 bytes, no padding is required. - * - * @param address - The Solana address to normalize (base58 string or PublicKey object) - * @returns UniversalAddress in hex format (0x + 64 hex characters) - * @throws {Error} When the Solana address format is invalid or conversion fails - * - * @example - * ```typescript - * // Base58 format - * const solanaAddr = 'So11111111111111111111111111111111111111112'; - * const universal = AddressNormalizer.normalizeSvm(solanaAddr); - * - * // PublicKey object also supported - * const publicKey = new PublicKey(solanaAddr); - * const universal2 = AddressNormalizer.normalizeSvm(publicKey); - * ``` - */ - static normalizeSvm(address: SvmAddress | PublicKey): UniversalAddress { - try { - // Create PublicKey from the address - const publicKey = address instanceof PublicKey ? address : new PublicKey(address); - - // Convert to bytes and then to hex - const bytes = publicKey.toBytes(); - const hex = '0x' + Buffer.from(bytes).toString('hex'); - - // Solana addresses are already 32 bytes, so no padding needed - return hex as UniversalAddress; - } catch (error) { - throw new Error(`Failed to normalize SVM address ${address}: ${getErrorMessage(error)}`); - } - } -} diff --git a/src/core/utils/chain-detector.ts b/src/core/utils/chain-detector.ts deleted file mode 100644 index c93cbec..0000000 --- a/src/core/utils/chain-detector.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Chain Type Detector Utility - * - * Provides utilities for determining blockchain types (EVM, TVM, SVM) from chain identifiers - * and validating address formats for cross-chain operations. This is essential for the - * Routes CLI system to handle multi-chain intent publishing correctly. - * - * Supports: - * - EVM chains: Ethereum, Optimism, Base, Arbitrum, etc. - * - TVM chains: Tron mainnet and testnets - * - SVM chains: Solana mainnet, devnet, and testnet - * - * @example - * ```typescript - * // Detect chain type from ID - * const chainType = ChainTypeDetector.detect(1); // ChainType.EVM (Ethereum) - * const solanaType = ChainTypeDetector.detect(1399811149); // ChainType.SVM - * - * // Validate address format - * const isValid = ChainTypeDetector.isValidAddressForChain( - * '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b', - * ChainType.EVM - * ); // true - * ``` - */ - -import { Network } from '@/commons/idls/portal.idl'; -import { getChainById } from '@/config/chains'; -import { ChainType } from '@/core/interfaces/intent'; - -/** - * Chain ID ranges and specific identifiers for different blockchain types - */ -const CHAIN_TYPE_MAPPINGS = { - // TVM chain IDs (Tron-specific) - TVM_CHAIN_IDS: [ - 728126428, // Tron mainnet - 2494104990, // Tron Shasta testnet - // Add more TVM chain IDs as needed - ], - - // SVM chain IDs (Solana-specific numeric IDs) - SVM_CHAIN_IDS: [ - 1399811149, // Solana mainnet - 1399811150, // Solana devnet - 1399811151, // Solana testnet - ], -}; - -export class ChainTypeDetector { - /** - * Detects chain type from numeric chain ID. - * - * Uses predefined mappings for known TVM and SVM chains, falling back to - * EVM detection heuristics for unrecognized chain IDs. String identifiers - * are deprecated in favor of numeric chain IDs. - * - * @param chainIdentifier - Chain ID as number, bigint, or deprecated string - * @returns ChainType enum value (EVM, TVM, or SVM) - * @throws {Error} When chain type cannot be determined or string identifier is used - * - * @example - * ```typescript - * // Ethereum mainnet - * const ethType = ChainTypeDetector.detect(1); // ChainType.EVM - * - * // Tron mainnet - * const tronType = ChainTypeDetector.detect(728126428); // ChainType.TVM - * - * // Solana mainnet - * const solanaType = ChainTypeDetector.detect(1399811149); // ChainType.SVM - * - * // Bigint support - * const chainType = ChainTypeDetector.detect(1n); // ChainType.EVM - * ``` - */ - static detect(chainIdentifier: bigint | number | string): ChainType { - // Handle legacy string identifiers (deprecated - should be numeric) - if (typeof chainIdentifier === 'string') { - throw new Error( - `String chain identifiers are deprecated. Use numeric chain IDs instead: ${chainIdentifier}` - ); - } - - // Convert bigint to number for comparison - const chainId = typeof chainIdentifier === 'bigint' ? Number(chainIdentifier) : chainIdentifier; - - // Check SVM chains first - if (CHAIN_TYPE_MAPPINGS.SVM_CHAIN_IDS.includes(chainId)) { - return ChainType.SVM; - } - - // Check TVM chains - if (CHAIN_TYPE_MAPPINGS.TVM_CHAIN_IDS.includes(chainId)) { - return ChainType.TVM; - } - - // Default heuristics for unknown chains (likely EVM) - if (this.isLikelyEvmChainId(chainId)) { - return ChainType.EVM; - } - - throw new Error(`Cannot determine chain type for chain ID: ${chainId}`); - } - - /** - * Gets the native address format description for a chain type. - * - * Provides human-readable descriptions of address formats used by different - * blockchain types. Useful for user interfaces and validation messages. - * - * @param chainType - The blockchain type to get format for - * @returns Human-readable address format description - * @throws {Error} When chain type is unknown - * - * @example - * ```typescript - * const evmFormat = ChainTypeDetector.getAddressFormat(ChainType.EVM); - * // Returns: 'hex (0x prefixed, 20 bytes)' - * - * const solanaFormat = ChainTypeDetector.getAddressFormat(ChainType.SVM); - * // Returns: 'base58 (Solana format, 32 bytes)' - * ``` - */ - static getAddressFormat(chainType: ChainType): string { - switch (chainType) { - case ChainType.EVM: - return 'hex (0x prefixed, 20 bytes)'; - case ChainType.TVM: - return 'base58 (Tron format)'; - case ChainType.SVM: - return 'base58 (Solana format, 32 bytes)'; - default: - throw new Error(`Unknown chain type: ${chainType}`); - } - } - - /** - * Validates if an address format matches the expected chain type. - * - * Performs regex-based validation to check if an address string conforms - * to the expected format for a given blockchain type. Does not validate - * checksums or verify the address exists on-chain. - * - * @param address - Address string to validate - * @param chainType - Expected blockchain type for validation - * @returns True if address format matches the chain type, false otherwise - * - * @example - * ```typescript - * // Valid EVM address - * const isValidEvm = ChainTypeDetector.isValidAddressForChain( - * '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b', - * ChainType.EVM - * ); // true - * - * // Invalid EVM address (wrong format) - * const isInvalid = ChainTypeDetector.isValidAddressForChain( - * 'TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH', - * ChainType.EVM - * ); // false - * - * // Valid Tron address - * const isValidTron = ChainTypeDetector.isValidAddressForChain( - * 'TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH', - * ChainType.TVM - * ); // true - * ``` - */ - static isValidAddressForChain(address: string, chainType: ChainType): boolean { - switch (chainType) { - case ChainType.EVM: - return /^0x[a-fA-F0-9]{40}$/.test(address); - case ChainType.TVM: - // Tron addresses start with T and are 34 characters long - return /^T[A-Za-z0-9]{33}$/.test(address); - case ChainType.SVM: - // Solana addresses are base58 encoded, typically 32-44 characters - return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address); - default: - return false; - } - } - - /** - * Checks if a chain ID follows EVM conventions - * EVM chain IDs are typically positive integers within reasonable ranges - * - * @param chainId - Numeric chain ID - * @returns true if likely an EVM chain - */ - private static isLikelyEvmChainId(chainId: number): boolean { - // EVM chain IDs are typically: - // - Positive integers - // - Less than 2^32 (4,294,967,296) - return ( - Number.isInteger(chainId) && - chainId > 0 && - chainId < 4_294_967_296 && - !CHAIN_TYPE_MAPPINGS.TVM_CHAIN_IDS.includes(chainId) - ); - } - - /** - * Determines the network (mainnet/devnet) from chain configuration - * - * @param chainId - Chain ID to look up - * @returns Network enum value - * @throws Error if chain is not found - */ - static getNetworkFromChainConfig(chainId: bigint): Network { - const chainConfig = getChainById(chainId); - if (!chainConfig) { - throw new Error(`Unknown chain: ${chainId}`); - } - - return chainConfig.env === 'production' ? Network.MAINNET : Network.DEVNET; - } -} diff --git a/src/core/utils/intent-converter.ts b/src/core/utils/intent-converter.ts deleted file mode 100644 index ea8bee4..0000000 --- a/src/core/utils/intent-converter.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Intent Converter Utility - * - * Provides conversion functions to transform Intent objects with UniversalAddresses - * into blockchain-specific formats. This is primarily used by publishers before - * encoding data for blockchain transactions. - * - * The converter handles address denormalization from UniversalAddress format to - * chain-native formats (EVM hex, Tron base58, Solana base58) while preserving - * all other intent data intact. - * - * @example - * ```typescript - * // Convert intent to EVM-compatible format - * const evmIntent = toEVMIntent(universalIntent); - * - * // Convert individual components - * const evmRoute = toRouteEVMIntent(universalIntent.route); - * const evmReward = toRewardEVMIntent(universalIntent.reward); - * ``` - */ - -import { Intent } from '../interfaces/intent'; - -import { AddressNormalizer } from './address-normalizer'; - -/** - * Converts a normalized Intent to EVM-specific intent format. - * - * Takes an Intent with UniversalAddresses and converts all addresses to EVM format - * (checksummed hex strings). The intent structure remains the same, but all - * addresses are denormalized for EVM blockchain compatibility. - * - * @param intent - Intent object with UniversalAddresses - * @returns Intent object with EVM-format addresses - * - * @example - * ```typescript - * const universalIntent = { - * intentHash: '0x123...', - * destination: ChainType.EVM, - * sourceChainId: 1n, - * route: { portal: '0x000...742d35cc...', ... }, - * reward: { creator: '0x000...567abc...', ... } - * }; - * - * const evmIntent = toEVMIntent(universalIntent); - * // evmIntent.route.portal is now '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b' - * ``` - */ -export function toEVMIntent(intent: Intent) { - return { - intentHash: intent.intentHash, - destination: intent.destination, - sourceChainId: intent.sourceChainId, - route: toRouteEVMIntent(intent.route), - reward: toRewardEVMIntent(intent.reward), - }; -} - -/** - * Converts a reward object to EVM-specific format. - * - * Denormalizes all addresses in the reward structure from UniversalAddress - * format to EVM hex format. Used when preparing reward data for EVM chains. - * - * @param reward - Reward object with UniversalAddresses - * @returns Reward object with EVM-format addresses - * - * @example - * ```typescript - * const reward = { - * creator: '0x000...742d35cc...', // UniversalAddress - * prover: '0x000...567abc...', // UniversalAddress - * tokens: [{ token: '0x000...def123...', amount: 1000n }], - * // ... other properties - * }; - * - * const evmReward = toRewardEVMIntent(reward); - * // evmReward.creator is now '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b' - * ``` - */ -export function toRewardEVMIntent(reward: Intent['reward']) { - return { - deadline: reward.deadline, - creator: AddressNormalizer.denormalizeToEvm(reward.creator), - prover: AddressNormalizer.denormalizeToEvm(reward.prover), - nativeAmount: reward.nativeAmount, - tokens: reward.tokens.map(token => ({ - amount: token.amount, - token: AddressNormalizer.denormalizeToEvm(token.token), - })), - }; -} - -/** - * Converts a route object to EVM-specific format. - * - * Denormalizes all addresses in the route structure from UniversalAddress - * format to EVM hex format. This includes portal address, token addresses, - * and call target addresses. - * - * @param route - Route object with UniversalAddresses - * @returns Route object with EVM-format addresses - * - * @example - * ```typescript - * const route = { - * portal: '0x000...742d35cc...', // UniversalAddress - * tokens: [{ token: '0x000...def123...', amount: 1000n }], - * calls: [{ target: '0x000...abc456...', data: '0x', value: 0n }], - * // ... other properties - * }; - * - * const evmRoute = toRouteEVMIntent(route); - * // evmRoute.portal is now '0x742d35Cc6634C0532925a3b8D65C32c2b3f6dE1b' - * // evmRoute.calls[0].target is now in EVM hex format - * ``` - */ -export function toRouteEVMIntent(route: Intent['route']) { - return { - salt: route.salt, - deadline: route.deadline, - portal: AddressNormalizer.denormalizeToEvm(route.portal), - nativeAmount: route.nativeAmount, - tokens: route.tokens.map(token => ({ - amount: token.amount, - token: AddressNormalizer.denormalizeToEvm(token.token), - })), - calls: route.calls.map(call => ({ - data: call.data, - target: AddressNormalizer.denormalizeToEvm(call.target), - value: call.value, - })), - }; -} diff --git a/src/core/utils/portal-encoder.ts b/src/core/utils/portal-encoder.ts deleted file mode 100644 index 5ef2b06..0000000 --- a/src/core/utils/portal-encoder.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * Portal Encoder Utility - * - * Provides chain-specific encoding and decoding for Portal contract data structures - * used in cross-chain intent publishing. This utility handles the serialization/ - * deserialization of Route and Reward data for different blockchain types. - * - * Encoding formats by chain type: - * - EVM: ABI encoding using viem library (produces hex strings) - * - TVM: ABI encoding compatible with Tron (produces hex strings) - * - SVM: Borsh serialization for Solana programs (produces binary data) - * - * The encoder automatically handles Universal Address normalization/denormalization - * internally to ensure addresses are in the correct format for each blockchain. - * - * @example - * ```typescript - * // Encode route data for EVM chains - * const encoded = PortalEncoder.encode(intent.route, ChainType.EVM); - * - * // Decode reward data from Solana - * const reward = PortalEncoder.decode(encodedData, ChainType.SVM, 'reward'); - * ``` - */ - -import { decodeAbiParameters, encodeAbiParameters, Hex } from 'viem'; - -import { EVMRewardAbiItem, EVMRouteAbiItem } from '@/commons/abis/portal.abi'; -import { RewardInstruction, RouteInstruction } from '@/commons/types/portal-idl-coder.type'; -import { bufferToBytes, bytes32ToAddress } from '@/commons/utils/converter'; -import { toSvmRewardForCoder, toSvmRouteForCoder } from '@/commons/utils/instruments'; -import { portalBorshCoder } from '@/commons/utils/portal-borsh-coder'; -import { TvmUtils } from '@/commons/utils/tvm-utils'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; - -import { ChainType, Intent } from '../interfaces/intent'; - -export class PortalEncoder { - /** - * Encodes Intent data for a specific chain type. - * - * Converts Route or Reward data from the Intent structure into the appropriate - * format for the target blockchain. Automatically handles address denormalization - * from UniversalAddress to chain-native formats before encoding. - * - * @param data - Data to encode (Route or Reward from Intent structure) - * @param chainType - Target chain type determining the encoding method - * @returns Encoded data as hexadecimal string with 0x prefix - * @throws {Error} When the chain type is unsupported or encoding fails - * - * @example - * ```typescript - * // Encode route for EVM deployment - * const encodedRoute = PortalEncoder.encode(intent.route, ChainType.EVM); - * - * // Encode reward for Solana program - * const encodedReward = PortalEncoder.encode(intent.reward, ChainType.SVM); - * ``` - */ - static encode(data: Intent['route'] | Intent['reward'], chainType: ChainType): Hex { - switch (chainType) { - case ChainType.EVM: - case ChainType.TVM: - return this.encodeEvm(data); - case ChainType.SVM: - return this.encodeSvm(data); - default: - throw new Error(`Unsupported chain type: ${chainType}`); - } - } - - /** - * Decodes data from a specific chain type to Intent format. - * - * Converts encoded blockchain data back into the standardized Intent structure. - * Automatically handles address normalization from chain-native formats to - * UniversalAddress during the decoding process. - * - * @param data - Encoded data as Buffer or hex string - * @param chainType - Source chain type that determines the decoding method - * @param dataType - Type of data structure to decode ('route' or 'reward') - * @returns Decoded Route or Reward object in Intent format with UniversalAddresses - * @throws {Error} When the chain type is unsupported or decoding fails - * - * @example - * ```typescript - * // Decode route data from EVM transaction - * const route = PortalEncoder.decode( - * encodedData, - * ChainType.EVM, - * 'route' - * ); - * - * // Decode reward data from Solana program account - * const reward = PortalEncoder.decode( - * accountData, - * ChainType.SVM, - * 'reward' - * ); - * ``` - */ - static decode( - data: Buffer | string, - chainType: ChainType, - dataType: Type - ): Type extends 'route' ? Intent['route'] : Intent['reward'] { - switch (chainType) { - case ChainType.EVM: - return this.decodeEvm(data, dataType); - case ChainType.TVM: - return this.decodeTvm(data, dataType); - case ChainType.SVM: - return this.decodeSvm(data, dataType); - default: - throw new Error(`Unsupported chain type: ${chainType}`); - } - } - - /** - * Type guard to determine if data is a Route. - * - * Distinguishes between Route and Reward data structures by checking for - * Route-specific properties like 'salt', 'portal', and 'calls'. - * - * @param data - Intent data to check (Route or Reward) - * @returns True if data is a Route, false if it's a Reward - * - * @example - * ```typescript - * if (PortalEncoder.isRoute(intentData)) { - * // Handle as Route - * console.log(`Route has ${intentData.calls.length} calls`); - * } else { - * // Handle as Reward - * console.log(`Reward creator: ${intentData.creator}`); - * } - * ``` - */ - static isRoute(data: Intent['route'] | Intent['reward']): data is Intent['route'] { - return 'salt' in data && 'portal' in data && 'calls' in data; - } - - /** - * EVM encoding using ABI parameters - */ - private static encodeEvm(data: Intent['route'] | Intent['reward']): Hex { - if (this.isRoute(data)) { - return encodeAbiParameters( - [EVMRouteAbiItem], - [ - { - salt: data.salt, - deadline: data.deadline, - nativeAmount: data.nativeAmount, - portal: AddressNormalizer.denormalizeToEvm(data.portal), - tokens: data.tokens.map(t => ({ - token: AddressNormalizer.denormalizeToEvm(t.token), - amount: t.amount, - })), - calls: data.calls.map(c => ({ - target: AddressNormalizer.denormalizeToEvm(c.target), - data: c.data, - value: c.value, - })), - }, - ] - ); - } else { - return encodeAbiParameters( - [EVMRewardAbiItem], - [ - { - deadline: data.deadline, - creator: AddressNormalizer.denormalizeToEvm(data.creator), - prover: AddressNormalizer.denormalizeToEvm(data.prover), - nativeAmount: data.nativeAmount, - tokens: data.tokens.map(t => ({ - token: AddressNormalizer.denormalizeToEvm(t.token), - amount: t.amount, - })), - }, - ] - ); - } - } - - /** - * SVM encoding using proper Borsh serialization - */ - private static encodeSvm(data: Intent['route'] | Intent['reward']): Hex { - if (PortalEncoder.isRoute(data)) { - return bufferToBytes( - portalBorshCoder.types.encode('Route', toSvmRouteForCoder(data)) - ); - } else { - return bufferToBytes( - portalBorshCoder.types.encode('Reward', toSvmRewardForCoder(data)) - ); - } - } - - /** - * EVM decoding to Intent format - */ - private static decodeEvm( - data: Buffer | string, - dataType: Type - ): Type extends 'route' ? Intent['route'] : Intent['reward'] { - const dataString = typeof data === 'string' ? data : '0x' + data.toString('hex'); - - if (dataType === 'reward') { - const decoded = decodeAbiParameters([EVMRewardAbiItem], dataString as Hex)[0]; - - return { - deadline: decoded.deadline, - creator: AddressNormalizer.normalize(decoded.creator, ChainType.EVM), - prover: AddressNormalizer.normalize(decoded.prover, ChainType.EVM), - nativeAmount: decoded.nativeAmount, - tokens: decoded.tokens.map(t => ({ - token: AddressNormalizer.normalize(t.token, ChainType.EVM), - amount: t.amount, - })), - } as Intent['reward'] as Type extends 'route' ? Intent['route'] : Intent['reward']; - } - - const decoded = decodeAbiParameters([EVMRouteAbiItem], dataString as Hex)[0]; - return { - salt: decoded.salt, - deadline: decoded.deadline, - portal: AddressNormalizer.normalize(decoded.portal, ChainType.EVM), - nativeAmount: decoded.nativeAmount || 0n, - tokens: decoded.tokens.map(t => ({ - token: AddressNormalizer.normalize(t.token, ChainType.EVM), - amount: t.amount, - })), - calls: decoded.calls.map(c => ({ - target: AddressNormalizer.normalize(c.target, ChainType.EVM), - data: c.data, - value: c.value, - })), - } as Intent['route'] as Type extends 'route' ? Intent['route'] : Intent['reward']; - } - - /** - * EVM decoding to Intent format - */ - private static decodeTvm( - data: Buffer | string, - dataType: Type - ): Type extends 'route' ? Intent['route'] : Intent['reward'] { - const dataString = typeof data === 'string' ? data : '0x' + data.toString('hex'); - - if (dataType === 'reward') { - const decoded = decodeAbiParameters([EVMRewardAbiItem], dataString as Hex)[0]; - - return { - deadline: decoded.deadline, - creator: AddressNormalizer.normalize(TvmUtils.fromEvm(decoded.creator), ChainType.TVM), - prover: AddressNormalizer.normalize(TvmUtils.fromEvm(decoded.prover), ChainType.TVM), - nativeAmount: decoded.nativeAmount, - tokens: decoded.tokens.map(t => ({ - token: AddressNormalizer.normalize(TvmUtils.fromEvm(t.token), ChainType.TVM), - amount: t.amount, - })), - } as Intent['reward'] as Type extends 'route' ? Intent['route'] : Intent['reward']; - } - - const decoded = decodeAbiParameters([EVMRouteAbiItem], dataString as Hex)[0]; - return { - salt: decoded.salt, - deadline: decoded.deadline, - portal: AddressNormalizer.normalize(TvmUtils.fromEvm(decoded.portal), ChainType.TVM), - nativeAmount: decoded.nativeAmount || 0n, - tokens: decoded.tokens.map(t => ({ - token: AddressNormalizer.normalize(TvmUtils.fromEvm(t.token), ChainType.TVM), - amount: t.amount, - })), - calls: decoded.calls.map(c => ({ - target: AddressNormalizer.normalize(TvmUtils.fromEvm(c.target), ChainType.TVM), - data: c.data, - value: c.value, - })), - } as Intent['route'] as Type extends 'route' ? Intent['route'] : Intent['reward']; - } - - /** - * SVM decoding from Borsh to Intent format - */ - private static decodeSvm( - data: Buffer | string, - dataType: Type - ): Type extends 'route' ? Intent['route'] : Intent['reward'] { - const buffer = - typeof data === 'string' - ? Buffer.from(data.startsWith('0x') ? data.substring(2) : data, 'hex') - : data; - - if (dataType === 'route') { - // Decode route using Borsh - const decoded = portalBorshCoder.types.decode('Route', buffer); - - if (decoded === null) { - throw new Error('Unable to decode SVM route'); - } - - const route: Intent['route'] = { - salt: bufferToBytes(decoded.salt[0]), - deadline: BigInt(decoded.deadline.toString()), - portal: AddressNormalizer.normalizeSvm(bytes32ToAddress(decoded.portal[0])), - nativeAmount: BigInt(decoded.native_amount.toString()), // Route doesn't have nativeAmount in the schema - tokens: decoded.tokens.map(t => ({ - token: AddressNormalizer.normalizeSvm(t.token), - amount: BigInt(t.amount.toString()), - })), - calls: decoded.calls.map(c => ({ - target: AddressNormalizer.normalizeSvm(bytes32ToAddress(c.target[0])), - data: bufferToBytes(c.data), - value: 0n, // Value is not part of the Call struct - })), - }; - - return route as Type extends 'route' ? Intent['route'] : Intent['reward']; - } - - // Decode reward using Borsh - const decoded = portalBorshCoder.types.decode('Reward', buffer); - - if (decoded === null) { - throw new Error('Unable to decode SVM reward'); - } - - const reward: Intent['reward'] = { - deadline: BigInt(decoded.deadline.toString()), - creator: AddressNormalizer.normalizeSvm(decoded.creator), - prover: AddressNormalizer.normalizeSvm(decoded.prover), - nativeAmount: BigInt(decoded.native_amount.toString()), - tokens: decoded.tokens.map(t => ({ - token: AddressNormalizer.normalizeSvm(t.token), - amount: BigInt(t.amount.toString()), - })), - }; - - return reward as Type extends 'route' ? Intent['route'] : Intent['reward']; - } -} diff --git a/src/core/utils/quote.ts b/src/core/utils/quote.ts deleted file mode 100644 index 2be527e..0000000 --- a/src/core/utils/quote.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Address } from 'viem'; - -import { logger } from '@/utils/logger'; - -interface QuoteRequest { - source: bigint; - destination: bigint; - amount: bigint; - funder: string; - recipient: string; - routeToken: string; - rewardToken: string; -} - -// Solver-v2 response format -interface SolverV2QuoteData { - intentExecutionType?: 'SELF_PUBLISH' | 'GASLESS'; - sourceChainID: number; - destinationChainID: number; - sourceToken: string; - destinationToken: string; - sourceAmount: string; - destinationAmount: string; - funder: string; - refundRecipient: string; - recipient: string; - encodedRoute: string; - fees: Array<{ - name: string; - description: string; - token: { - address: Address; - decimals: number; - symbol: string; - }; - amount: string; - }>; - deadline: number; - estimatedFulfillTimeSec?: number; -} - -// Updated QuoteResponse to handle both formats -export interface QuoteResponse { - // Quote service v3 format (wrapped in 'data') - quoteResponse?: { - sourceChainID: number; - destinationChainID: number; - sourceToken: string; - destinationToken: string; - sourceAmount: string; - destinationAmount: string; - funder: string; - refundRecipient: string; - recipient: string; - encodedRoute: string; - fees: [ - { - name: string; - description: string; - token: { - address: Address; - decimals: 18; - symbol: string; - }; - amount: string; - }, - ]; - deadline: number; - estimatedFulfillTimeSec?: number; - intentExecutionType?: 'SELF_PUBLISH' | 'GASLESS'; - }; - // Solver-v2 format (array response) - quoteResponses?: SolverV2QuoteData[]; - // Common to both - contracts: { - sourcePortal: Address; - prover: Address; - destinationPortal: Address; - }; -} - -function getQuoteUrl(): string { - // Priority 1: Use solver-v2 if SOLVER_URL is set - if (process.env.SOLVER_URL) { - const baseUrl = process.env.SOLVER_URL.replace(/\/$/, ''); // Remove trailing slash - return `${baseUrl}/api/v2/quote/reverse`; - } - - // Priority 2: Use preprod quote service if flags are set - if (process.env.QUOTES_API_URL || process.env.QUOTES_PREPROD) { - return 'https://quotes-preprod.eco.com/api/v3/quotes/single'; - } - - // Priority 3: Default to production quote service - return 'https://quotes.eco.com/api/v3/quotes/single'; -} - -// Determine if we're using solver-v2 API -function isSolverV2(): boolean { - return !!process.env.SOLVER_URL; -} - -export async function getQuote(requestOpts: QuoteRequest) { - const quoteUrl = getQuoteUrl(); - const usingSolverV2 = isSolverV2(); - - const request: any = { - dAppID: 'eco-routes-cli', - quoteRequest: { - // For solver-v2, keep as string; for quote service, convert to number - sourceChainID: usingSolverV2 ? requestOpts.source.toString() : Number(requestOpts.source), - sourceToken: requestOpts.rewardToken, - destinationChainID: usingSolverV2 - ? requestOpts.destination.toString() - : Number(requestOpts.destination), - destinationToken: requestOpts.routeToken, - sourceAmount: requestOpts.amount.toString(), - funder: requestOpts.funder, - recipient: requestOpts.recipient, - }, - }; - - if (usingSolverV2) { - request.quoteID = crypto.randomUUID(); - request.intentExecutionTypes = ['SELF_PUBLISH']; - } - - if (process.env.DEBUG) { - logger.log(`Calling quoting service: ${quoteUrl}`); - logger.log(`Quote request: ${JSON.stringify(request)}`); - } - - const response = await fetch(quoteUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }); - - const result = await response.json(); - - if (process.env.DEBUG) { - logger.log(`Quote: ${JSON.stringify(result, null, 2)}`); - } - - if (!response.ok) throw new Error(JSON.stringify(result)); - - // Handle different response formats - if (usingSolverV2) { - // Solver-v2 returns direct response with quoteResponses array - if (!result.quoteResponses || result.quoteResponses.length === 0) { - throw new Error('Invalid solver-v2 response: no quotes returned'); - } - - // Convert array format to single quoteResponse format for compatibility - return { - quoteResponse: result.quoteResponses[0], - contracts: result.contracts, - } as QuoteResponse; - } else { - // Quote service v3 returns wrapped response - return result.data ?? result; - } -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index d0c88af..0000000 --- a/src/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env node - -/** - * Intent Publisher CLI - * Main entry point - */ - -import chalk from 'chalk'; -import { Command } from 'commander'; - -import { createConfigCommand } from '@/commands/config'; -import { createPublishCommand } from '@/commands/publish'; -import { createStatusCommand } from '@/commands/status'; -import { type ChainConfig, updatePortalAddresses } from '@/config/chains'; -import { type TokenConfig } from '@/config/tokens'; -import { handleCliError, setupGlobalErrorHandlers } from '@/utils/error-handler'; -import { logger } from '@/utils/logger'; - -// Setup global error handling -setupGlobalErrorHandlers(); - -// Load environment variables and update configuration -try { - updatePortalAddresses(process.env); -} catch (error) { - handleCliError(error); -} - -// Create main program -const program = new Command(); - -program - .name('intent-cli') - .description('CLI tool for publishing intents to EVM, TVM, and SVM chains') - .version('1.0.0'); - -// Add commands -program.addCommand(createPublishCommand()); -program.addCommand(createStatusCommand()); -program.addCommand(createConfigCommand()); - -// List chains command -program - .command('chains') - .description('List supported chains') - .action(async () => { - const { listChains } = await import('@/config/chains'); - const chains = listChains(); - - logger.title('📋 Supported Chains'); - - const headers = ['Name', 'ID', 'Type', 'Native Currency']; - const rows = chains.map((chain: ChainConfig) => [ - chalk.yellow(chain.name), - chain.id.toString(), - chain.type, - chain.nativeCurrency.symbol, - ]); - - logger.displayTable(headers, rows); - }); - -// List tokens command -program - .command('tokens') - .description('List configured tokens') - .action(async () => { - const { listTokens } = await import('@/config/tokens'); - const tokens = listTokens(); - - logger.title('💰 Configured Tokens'); - - const headers = ['Symbol', 'Name', 'Decimals', 'Available Chains']; - const rows = tokens.map((token: TokenConfig) => [ - chalk.yellow(token.symbol), - token.name, - token.decimals, - Object.keys(token.addresses).join(', '), - ]); - - logger.displayTable(headers, rows, { - colWidths: [10, 25, 10, 35], - wordWrap: true, - }); - }); - -// Parse arguments with error handling -try { - program.parse(process.argv); -} catch (error) { - handleCliError(error); -} - -// Show help if no command provided -if (!process.argv.slice(2).length) { - program.outputHelp(); -} diff --git a/src/intent/intent-builder.service.ts b/src/intent/intent-builder.service.ts new file mode 100644 index 0000000..4f7793f --- /dev/null +++ b/src/intent/intent-builder.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; + +import { encodeFunctionData, erc20Abi, Hex } from 'viem'; + +import { AddressNormalizerService } from '@/blockchain/address-normalizer.service'; +import { PortalEncoderService } from '@/blockchain/encoding/portal-encoder.service'; +import { ConfigService } from '@/config/config.service'; +import { ChainConfig, Intent, UniversalAddress } from '@/shared/types'; + +export interface RewardParams { + sourceChain: ChainConfig; + creator: UniversalAddress; + prover: UniversalAddress; + rewardToken: UniversalAddress; + rewardAmount: bigint; + deadline?: bigint; +} + +export interface ManualRouteParams { + destChain: ChainConfig; + recipient: UniversalAddress; + routeToken: UniversalAddress; + routeAmount: bigint; + portal: UniversalAddress; + deadline?: bigint; +} + +@Injectable() +export class IntentBuilder { + constructor( + private readonly config: ConfigService, + private readonly encoder: PortalEncoderService, + private readonly normalizer: AddressNormalizerService + ) {} + + buildReward(params: RewardParams): Intent['reward'] { + const deadlineOffset = BigInt(this.config.getDeadlineOffsetSeconds()); + const deadline = params.deadline ?? BigInt(Math.floor(Date.now() / 1000)) + deadlineOffset; + + const rewardEvmAddr = this.normalizer.denormalizeToEvm(params.rewardToken); + const isNative = rewardEvmAddr === '0x0000000000000000000000000000000000000000'; + + return { + deadline, + creator: params.creator, + prover: params.prover, + nativeAmount: isNative ? params.rewardAmount : 0n, + tokens: isNative ? [] : [{ token: params.rewardToken, amount: params.rewardAmount }], + }; + } + + buildManualRoute(params: ManualRouteParams): { encodedRoute: Hex; route: Intent['route'] } { + const deadlineOffset = BigInt(this.config.getDeadlineOffsetSeconds()); + const deadline = params.deadline ?? BigInt(Math.floor(Date.now() / 1000)) + deadlineOffset; + const salt = this.generateSalt(); + + const routeEvmAddr = this.normalizer.denormalizeToEvm(params.routeToken); + const isNative = routeEvmAddr === '0x0000000000000000000000000000000000000000'; + + let route: Intent['route']; + + if (isNative) { + // Native ETH call: send value directly to recipient with empty calldata + route = { + salt, + deadline, + portal: params.portal, + nativeAmount: params.routeAmount, + tokens: [], + calls: [{ target: params.recipient, data: '0x', value: params.routeAmount }], + }; + } else { + // ERC-20 path: build transfer call to recipient + const recipientAddr = this.normalizer.denormalizeToEvm(params.recipient); + const transferData = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipientAddr, params.routeAmount], + }); + + route = { + salt, + deadline, + portal: params.portal, + nativeAmount: 0n, + tokens: [{ token: params.routeToken, amount: params.routeAmount }], + calls: [{ target: params.routeToken, data: transferData, value: 0n }], + }; + } + + const encodedRoute = this.encoder.encode(route, params.destChain.type); + return { encodedRoute, route }; + } + + private generateSalt(): Hex { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return `0x${Buffer.from(bytes).toString('hex')}` as Hex; + } +} diff --git a/src/intent/intent-storage.service.ts b/src/intent/intent-storage.service.ts new file mode 100644 index 0000000..62b9ab1 --- /dev/null +++ b/src/intent/intent-storage.service.ts @@ -0,0 +1,77 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { Injectable } from '@nestjs/common'; + +import { PublishResult } from '@/blockchain/base.publisher'; +import { Intent } from '@/shared/types'; + +export interface StoredIntent { + intentHash: string; + sourceChainId: string; + destChainId: string; + reward: unknown; + routeHash: string; + publishedAt: number; + refundedAt: number | null; + transactionHash: string; +} + +@Injectable() +export class IntentStorage { + private readonly storePath = path.join(os.homedir(), '.routes-cli', 'intents.json'); + + async save(intent: Intent, result: PublishResult): Promise { + const intents = await this.readAll(); + const entry: StoredIntent = { + intentHash: result.intentHash ?? '', + sourceChainId: intent.sourceChainId.toString(), + destChainId: intent.destination.toString(), + reward: intent.reward, + routeHash: '', + publishedAt: Math.floor(Date.now() / 1000), + refundedAt: null, + transactionHash: result.transactionHash ?? '', + }; + intents.push(entry); + await this.writeAll(intents); + } + + async findByHash(intentHash: string): Promise { + const intents = await this.readAll(); + return intents.find(i => i.intentHash === intentHash) ?? null; + } + + async listAll(): Promise { + return this.readAll(); + } + + async markRefunded(intentHash: string): Promise { + const intents = await this.readAll(); + const entry = intents.find(i => i.intentHash === intentHash); + if (entry) { + entry.refundedAt = Math.floor(Date.now() / 1000); + await this.writeAll(intents); + } + } + + private async readAll(): Promise { + try { + const raw = await fs.readFile(this.storePath, 'utf8'); + return JSON.parse(raw, (_, v) => + typeof v === 'string' && /^\d+n$/.test(v) ? BigInt(v.slice(0, -1)) : v + ); + } catch { + return []; + } + } + + private async writeAll(intents: StoredIntent[]): Promise { + await fs.mkdir(path.dirname(this.storePath), { recursive: true }); + await fs.writeFile( + this.storePath, + JSON.stringify(intents, (_, v) => (typeof v === 'bigint' ? `${v}n` : v), 2) + ); + } +} diff --git a/src/intent/intent.module.ts b/src/intent/intent.module.ts new file mode 100644 index 0000000..135209a --- /dev/null +++ b/src/intent/intent.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { IntentBuilder } from './intent-builder.service'; +import { IntentStorage } from './intent-storage.service'; + +@Module({ + providers: [IntentBuilder, IntentStorage], + exports: [IntentBuilder, IntentStorage], +}) +export class IntentModule {} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9405a9a --- /dev/null +++ b/src/main.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import { CommandFactory } from 'nest-commander'; + +import 'reflect-metadata'; + +import { AppModule } from './app.module'; + +async function bootstrap(): Promise { + const majorVersion = parseInt(process.version.slice(1).split('.')[0], 10); + if (majorVersion < 18) { + console.error(`Node.js >= 18 required. Current: ${process.version}`); + process.exit(1); + } + + if (process.argv.length <= 2) { + process.argv.push('--help'); + } + + await CommandFactory.run(AppModule, { + logger: false, + errorHandler: err => { + if (err.message === '(outputHelp)') process.exit(0); + console.error(err.message); + if (process.env['DEBUG']) console.error(err.stack); + process.exit(1); + }, + }); +} + +void bootstrap(); diff --git a/src/quote/quote.module.ts b/src/quote/quote.module.ts new file mode 100644 index 0000000..12c5e41 --- /dev/null +++ b/src/quote/quote.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { DisplayModule } from '@/cli/services/display.module'; + +import { QuoteService } from './quote.service'; + +@Module({ + imports: [DisplayModule], + providers: [QuoteService], + exports: [QuoteService], +}) +export class QuoteModule {} diff --git a/src/quote/quote.service.ts b/src/quote/quote.service.ts new file mode 100644 index 0000000..872cf4f --- /dev/null +++ b/src/quote/quote.service.ts @@ -0,0 +1,175 @@ +import { Injectable } from '@nestjs/common'; + +import { Address } from 'viem'; + +import { DisplayService } from '@/cli/services/display.service'; +import { ConfigService } from '@/config/config.service'; + +export interface QuoteRequest { + source: bigint; + destination: bigint; + amount: bigint; + funder: string; + recipient: string; + routeToken: string; + rewardToken: string; +} + +export interface QuoteResult { + encodedRoute: string; + sourcePortal: Address; + prover: Address; + deadline: number; + destinationAmount: string; + estimatedFulfillTimeSec?: number; + intentExecutionType?: 'SELF_PUBLISH' | 'GASLESS'; + destinationPortalAddress: Address; +} + +// Internal API response types + +interface SolverV2QuoteData { + intentExecutionType?: 'SELF_PUBLISH' | 'GASLESS'; + sourceChainID: number; + destinationChainID: number; + sourceToken: string; + destinationToken: string; + sourceAmount: string; + destinationAmount: string; + funder: string; + refundRecipient: string; + recipient: string; + encodedRoute: string; + fees: Array<{ + name: string; + description: string; + token: { address: Address; decimals: number; symbol: string }; + amount: string; + }>; + deadline: number; + estimatedFulfillTimeSec?: number; +} + +interface QuoteServiceV3Data { + encodedRoute: string; + deadline: number; + destinationAmount: string; + estimatedFulfillTimeSec?: number; + intentExecutionType?: 'SELF_PUBLISH' | 'GASLESS'; +} + +interface RawQuoteResponse { + quoteResponse?: QuoteServiceV3Data; + quoteResponses?: SolverV2QuoteData[]; + contracts: { + sourcePortal: Address; + prover: Address; + destinationPortal: Address; + }; +} + +interface QuoteRequestPayload { + dAppID: string; + quoteRequest: { + sourceChainID: number | string; + sourceToken: string; + destinationChainID: number | string; + destinationToken: string; + sourceAmount: string; + funder: string; + recipient: string; + }; + quoteID?: string; + intentExecutionTypes?: string[]; +} + +@Injectable() +export class QuoteService { + constructor( + private readonly config: ConfigService, + private readonly display: DisplayService + ) {} + + async getQuote(params: QuoteRequest): Promise { + const { url, type } = this.config.getQuoteEndpoint(); + const dAppID = this.config.getDappId(); + const isSolverV2 = type === 'solver-v2'; + + const request: QuoteRequestPayload = { + dAppID, + quoteRequest: { + sourceChainID: isSolverV2 ? params.source.toString() : Number(params.source), + sourceToken: params.rewardToken, + destinationChainID: isSolverV2 ? params.destination.toString() : Number(params.destination), + destinationToken: params.routeToken, + sourceAmount: params.amount.toString(), + funder: params.funder, + recipient: params.recipient, + }, + }; + + if (isSolverV2) { + request.quoteID = crypto.randomUUID(); + request.intentExecutionTypes = ['SELF_PUBLISH']; + } + + if (this.config.isDebug()) { + this.display.log( + `[DEBUG] Quote request: ${JSON.stringify({ url, request: JSON.stringify(request) })}` + ); + } + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + const raw = (await response.json()) as RawQuoteResponse; + if (this.config.isDebug()) { + this.display.log(`[DEBUG] Quote response: ${JSON.stringify(raw)}`); + } + if (!response.ok) throw new Error(JSON.stringify(raw)); + + // Solver-v2 returns the object directly; quote-service-v3 wraps in `data` + const data: RawQuoteResponse = isSolverV2 + ? raw + : ((raw as unknown as { data?: RawQuoteResponse }).data ?? raw); + + if (!data.contracts?.sourcePortal || !data.contracts?.prover) { + throw new Error('Quote response missing required contract addresses'); + } + + if (isSolverV2) { + if (!data.quoteResponses || data.quoteResponses.length === 0) { + throw new Error('Invalid solver-v2 response: no quotes returned'); + } + const q = data.quoteResponses[0]; + return { + encodedRoute: q.encodedRoute, + sourcePortal: data.contracts.sourcePortal, + prover: data.contracts.prover, + deadline: q.deadline, + destinationAmount: q.destinationAmount, + estimatedFulfillTimeSec: q.estimatedFulfillTimeSec, + intentExecutionType: q.intentExecutionType, + destinationPortalAddress: data.contracts.destinationPortal, + }; + } + + if (!data.quoteResponse) { + throw new Error('Quote response missing quote data'); + } + + return { + encodedRoute: data.quoteResponse.encodedRoute, + sourcePortal: data.contracts.sourcePortal, + prover: data.contracts.prover, + deadline: data.quoteResponse.deadline, + destinationAmount: data.quoteResponse.destinationAmount, + estimatedFulfillTimeSec: data.quoteResponse.estimatedFulfillTimeSec, + intentExecutionType: data.quoteResponse.intentExecutionType, + destinationPortalAddress: data.contracts.destinationPortal, + }; + } +} diff --git a/src/scripts/evm-intent-simple/scripts/evm-evm-intent.ts b/src/scripts/evm-intent-simple/scripts/evm-evm-intent.ts index 2d04ba0..c4201ab 100644 --- a/src/scripts/evm-intent-simple/scripts/evm-evm-intent.ts +++ b/src/scripts/evm-intent-simple/scripts/evm-evm-intent.ts @@ -10,14 +10,12 @@ * - Ethereum → Optimism (USDT transfer) */ -import * as dotenv from 'dotenv'; +import 'dotenv/config'; import { Hex } from 'viem'; import { base, optimism } from 'viem/chains'; import { IntentConfig, IntentCreator } from './intent-creator-base'; -dotenv.config(); - // ============================================================================ // Configuration Examples // ============================================================================ diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 0000000..d3a9731 --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1 @@ +export * from './routes-cli-error'; diff --git a/src/shared/errors/routes-cli-error.ts b/src/shared/errors/routes-cli-error.ts new file mode 100644 index 0000000..5e0dae4 --- /dev/null +++ b/src/shared/errors/routes-cli-error.ts @@ -0,0 +1,96 @@ +export enum ErrorCode { + INVALID_ADDRESS = 'INVALID_ADDRESS', + INVALID_PRIVATE_KEY = 'INVALID_PRIVATE_KEY', + INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', + UNSUPPORTED_CHAIN = 'UNSUPPORTED_CHAIN', + NETWORK_ERROR = 'NETWORK_ERROR', + TRANSACTION_FAILED = 'TRANSACTION_FAILED', + CONFIGURATION_ERROR = 'CONFIGURATION_ERROR', + QUOTE_SERVICE_ERROR = 'QUOTE_SERVICE_ERROR', +} + +export class RoutesCliError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + public readonly isUserError: boolean = false, + public readonly cause?: unknown + ) { + super(message); + this.name = 'RoutesCliError'; + Object.setPrototypeOf(this, RoutesCliError.prototype); + } + + static invalidAddress(addr: string, chainType?: string): RoutesCliError { + const chain = chainType ? ` for ${chainType}` : ''; + const formats: Record = { + EVM: '0x followed by 40 hex characters (e.g. 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045)', + TVM: 'T followed by 33 alphanumeric characters (e.g. TRXyyyy…)', + SVM: 'base58-encoded 32-byte public key (e.g. 11111111111111111111111111111111)', + }; + const formatHint = + chainType && formats[chainType] ? `\n Expected format: ${formats[chainType]}` : ''; + return new RoutesCliError( + ErrorCode.INVALID_ADDRESS, + `Invalid address${chain}: "${addr}"${formatHint}`, + true + ); + } + + static invalidPrivateKey(chainType: string): RoutesCliError { + const formats: Record = { + EVM: '0x followed by 64 hex characters (e.g. 0xac09…2ff80)', + TVM: '64 hex characters without 0x prefix (e.g. ac09…2ff80)', + SVM: 'base58 string, JSON byte array [1,2,…], or comma-separated bytes', + }; + const envVars: Record = { + EVM: 'EVM_PRIVATE_KEY', + TVM: 'TVM_PRIVATE_KEY', + SVM: 'SVM_PRIVATE_KEY', + }; + const expected = formats[chainType] ?? 'see documentation for the chain-specific format'; + const envVar = envVars[chainType] ?? `${chainType}_PRIVATE_KEY`; + return new RoutesCliError( + ErrorCode.INVALID_PRIVATE_KEY, + `No private key configured for ${chainType}.\n` + + ` Expected format: ${expected}\n` + + ` Fix: set ${envVar} in your .env file, or pass --private-key on the command line.`, + true + ); + } + + static insufficientBalance(required: bigint, available: bigint, token?: string): RoutesCliError { + const asset = token ?? 'native token'; + return new RoutesCliError( + ErrorCode.INSUFFICIENT_BALANCE, + `Insufficient ${asset} balance.\n` + + ` Required: ${required}\n` + + ` Available: ${available}\n` + + ` Fix: fund the sender address with at least ${required} ${asset} before publishing.`, + true + ); + } + + static unsupportedChain(chainId: bigint | string): RoutesCliError { + return new RoutesCliError( + ErrorCode.UNSUPPORTED_CHAIN, + `Unsupported chain: "${chainId}".\n` + + ` Run "routes-cli chains" to see all supported chains and their IDs.`, + true + ); + } + + static networkError(rpcUrl: string, cause: unknown): RoutesCliError { + const reason = cause instanceof Error ? cause.message : String(cause); + return new RoutesCliError( + ErrorCode.NETWORK_ERROR, + `Network error connecting to ${rpcUrl}: ${reason}`, + false, + cause + ); + } + + static configurationError(message: string): RoutesCliError { + return new RoutesCliError(ErrorCode.CONFIGURATION_ERROR, message, true); + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..23ea9c9 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,3 @@ +export * from './errors'; +export * from './security'; +export * from './types'; diff --git a/src/shared/security/index.ts b/src/shared/security/index.ts new file mode 100644 index 0000000..0edbaa3 --- /dev/null +++ b/src/shared/security/index.ts @@ -0,0 +1 @@ +export * from './key-handle'; diff --git a/src/shared/security/key-handle.ts b/src/shared/security/key-handle.ts new file mode 100644 index 0000000..65b1cee --- /dev/null +++ b/src/shared/security/key-handle.ts @@ -0,0 +1,40 @@ +/** + * A single-use wrapper around a private key string. + * + * Calling use() or useAsync() passes the key to a function and immediately + * zeroizes the internal buffer in a finally block, regardless of success or failure. + * + * use() — synchronous; buffer zeroed after fn() returns + * useAsync() — async-safe; buffer zeroed after the returned Promise settles + */ +export class KeyHandle { + private buffer: Buffer; + + constructor(key: string) { + this.buffer = Buffer.from(key, 'utf8'); + } + + /** + * Synchronous variant. Use for deriving wallet addresses or other + * synchronous key operations. Buffer is zeroed before any async work begins. + */ + use(fn: (key: string) => T): T { + try { + return fn(this.buffer.toString('utf8')); + } finally { + this.buffer.fill(0); + } + } + + /** + * Async-safe variant. Buffer is zeroed only after the promise resolves or rejects. + * Use this when the key needs to survive through async operations (e.g. publisher.publish). + */ + async useAsync(fn: (key: string) => Promise): Promise { + try { + return await fn(this.buffer.toString('utf8')); + } finally { + this.buffer.fill(0); + } + } +} diff --git a/src/core/types/blockchain-addresses.ts b/src/shared/types/blockchain-addresses.ts similarity index 100% rename from src/core/types/blockchain-addresses.ts rename to src/shared/types/blockchain-addresses.ts diff --git a/src/shared/types/chain-config.ts b/src/shared/types/chain-config.ts new file mode 100644 index 0000000..f4d2e3b --- /dev/null +++ b/src/shared/types/chain-config.ts @@ -0,0 +1,17 @@ +import { ChainType } from './intent.interface'; +import { UniversalAddress } from './universal-address'; + +export interface ChainConfig { + id: bigint; + name: string; + env: 'production' | 'development'; + type: ChainType; + rpcUrl: string; + portalAddress?: UniversalAddress; + proverAddress?: UniversalAddress; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + }; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..f282538 --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1,4 @@ +export * from './blockchain-addresses'; +export * from './chain-config'; +export * from './intent.interface'; +export * from './universal-address'; diff --git a/src/core/interfaces/intent.ts b/src/shared/types/intent.interface.ts similarity index 98% rename from src/core/interfaces/intent.ts rename to src/shared/types/intent.interface.ts index c112ee7..ee00cfb 100644 --- a/src/core/interfaces/intent.ts +++ b/src/shared/types/intent.interface.ts @@ -31,7 +31,7 @@ import { Hex } from 'viem'; -import { UniversalAddress } from '../types/universal-address'; +import { UniversalAddress } from './universal-address'; /** * Cross-chain intent object containing route execution and reward information. diff --git a/src/core/types/universal-address.ts b/src/shared/types/universal-address.ts similarity index 100% rename from src/core/types/universal-address.ts rename to src/shared/types/universal-address.ts diff --git a/src/status/status.module.ts b/src/status/status.module.ts new file mode 100644 index 0000000..4e25ce8 --- /dev/null +++ b/src/status/status.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { StatusService } from './status.service'; + +@Module({ + providers: [StatusService], + exports: [StatusService], +}) +export class StatusModule {} diff --git a/src/status/status.service.ts b/src/status/status.service.ts new file mode 100644 index 0000000..c125640 --- /dev/null +++ b/src/status/status.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; + +import { IntentStatus } from '@/blockchain/base.publisher'; +import { PublisherFactory } from '@/blockchain/publisher-factory.service'; +import { ChainConfig } from '@/shared/types'; + +export { IntentStatus }; + +@Injectable() +export class StatusService { + constructor(private readonly publisherFactory: PublisherFactory) {} + + async getStatus(intentHash: string, chain: ChainConfig): Promise { + const publisher = this.publisherFactory.create(chain); + return publisher.getStatus(intentHash, chain); + } + + async watch( + intentHash: string, + chain: ChainConfig, + onUpdate: (status: IntentStatus) => void, + options: { intervalMs?: number; timeoutMs?: number } = {} + ): Promise<'fulfilled' | 'timeout'> { + const { intervalMs = 10_000, timeoutMs } = options; + const startTime = Date.now(); + let last: IntentStatus | null = null; + + while (true) { + if (timeoutMs && Date.now() - startTime > timeoutMs) return 'timeout'; + + const status = await this.getStatus(intentHash, chain); + if (!last || status.fulfilled !== last.fulfilled) { + onUpdate(status); + last = status; + } + if (status.fulfilled) return 'fulfilled'; + + await new Promise(r => setTimeout(r, intervalMs)); + } + } +} diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts deleted file mode 100644 index 70302d8..0000000 --- a/src/utils/error-handler.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Global error handling utilities - */ - -import { hasErrorCode } from '@/commons/utils/error-handler'; -import { logger } from '@/utils/logger'; - -export interface ErrorWithCode extends Error { - code?: string; - statusCode?: number; -} - -export class CliError extends Error { - public readonly code: string; - public readonly isOperational: boolean; - public readonly statusCode: number; - - constructor( - message: string, - code: string = 'CLI_ERROR', - statusCode: number = 1, - isOperational: boolean = true - ) { - super(message); - this.name = 'CliError'; - this.code = code; - this.statusCode = statusCode; - this.isOperational = isOperational; - - // Maintains proper stack trace for where our error was thrown - if (Error.captureStackTrace) { - Error.captureStackTrace(this, CliError); - } - } -} - -export class NetworkError extends CliError { - constructor(message: string, originalError?: Error) { - super( - `Network error: ${message}${originalError ? ` (${originalError.message})` : ''}`, - 'NETWORK_ERROR', - 1 - ); - } -} - -export class ValidationError extends CliError { - constructor(message: string) { - super(`Validation error: ${message}`, 'VALIDATION_ERROR', 1); - } -} - -export class ConfigurationError extends CliError { - constructor(message: string) { - super(`Configuration error: ${message}`, 'CONFIG_ERROR', 1); - } -} - -export class BlockchainError extends CliError { - constructor(message: string, chainType?: string) { - super( - `Blockchain error${chainType ? ` (${chainType})` : ''}: ${message}`, - 'BLOCKCHAIN_ERROR', - 1 - ); - } -} - -/** - * Global error handler for uncaught exceptions and unhandled rejections - */ -export function setupGlobalErrorHandlers(): void { - // Handle uncaught exceptions - process.on('uncaughtException', (error: Error) => { - logger.error('Uncaught Exception:'); - logger.error(error.stack || error.message); - - // Attempt to cleanup and exit gracefully - setTimeout(() => { - process.exit(1); - }, 1000); - }); - - // Handle unhandled promise rejections - process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { - logger.error('Unhandled Rejection at Promise'); - logger.error(`Reason: ${String(reason)}`); - - // Exit gracefully - process.exit(1); - }); - - // Handle process termination signals - process.on('SIGTERM', () => { - logger.info('Received SIGTERM, shutting down gracefully'); - process.exit(0); - }); - - process.on('SIGINT', () => { - logger.info('Received SIGINT, shutting down gracefully'); - process.exit(0); - }); -} - -/** - * Handles CLI errors with appropriate logging and exit codes - */ -export function handleCliError(error: unknown): never { - if (error instanceof CliError) { - // Our custom CLI errors - logger.error(error.message); - - if (process.env.DEBUG && error.stack) { - logger.error('Stack trace:'); - logger.error(error.stack); - } - - process.exit(error.statusCode); - } else if (hasErrorCode(error) && error.code === 'ENOENT') { - // File not found errors - const path = 'path' in error ? (error as Error & { path: string }).path : 'unknown'; - logger.error(`File not found: ${path}`); - process.exit(1); - } else if (hasErrorCode(error) && error.code === 'EACCES') { - // Permission errors - const path = - 'path' in error ? (error as Error & { path: string }).path : 'unknown file/directory'; - logger.error(`Permission denied: ${path}`); - logger.error('Try running with appropriate permissions or check file ownership'); - process.exit(1); - } else if (hasErrorCode(error) && (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND')) { - // Network connection errors - logger.error('Network connection failed'); - logger.error('Please check your internet connection and try again'); - process.exit(1); - } else if (error instanceof Error && error.name === 'ValidationError') { - // Validation errors from libraries - logger.error(`Input validation failed: ${error.message}`); - process.exit(1); - } else { - // Generic errors - logger.error('An unexpected error occurred:'); - const message = error instanceof Error ? error.message : String(error); - logger.error(message); - - if (process.env.DEBUG && error instanceof Error && error.stack) { - logger.error('Stack trace:'); - logger.error(error.stack); - } - - process.exit(1); - } -} - -/** - * Wraps async functions to handle errors gracefully - */ -export function withErrorHandling( - fn: (...args: T) => Promise -): (...args: T) => Promise { - return async (...args: T): Promise => { - try { - return await fn(...args); - } catch (error) { - handleCliError(error); - } - }; -} - -/** - * Creates a retry wrapper for operations that might fail temporarily - */ -export function withRetry( - fn: (...args: T) => Promise, - maxRetries: number = 3, - delayMs: number = 1000 -): (...args: T) => Promise { - return async (...args: T): Promise => { - let lastError: unknown; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await fn(...args); - } catch (error) { - lastError = error; - - if (attempt === maxRetries) { - break; - } - - // Only retry on network errors or temporary failures - const errorWithCode = error as ErrorWithCode; - if ( - error instanceof NetworkError || - errorWithCode?.code === 'ECONNREFUSED' || - errorWithCode?.code === 'ETIMEDOUT' || - errorWithCode?.code === 'ENOTFOUND' - ) { - logger.warn(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`); - await new Promise(resolve => setTimeout(resolve, delayMs)); - delayMs *= 1.5; // Exponential backoff - } else { - // Don't retry non-recoverable errors - break; - } - } - } - - throw lastError; - }; -} - -/** - * Validates required environment variables - */ -export function validateEnvironment(requiredVars: string[]): void { - const missing = requiredVars.filter(varName => !process.env[varName]); - - if (missing.length > 0) { - throw new ConfigurationError( - `Missing required environment variables: ${missing.join(', ')}\n` + - 'Please check your .env file or set these variables in your environment.' - ); - } -} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 5a711ab..904d65c 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /** * Logger utility module for enhanced CLI output * Uses ora for spinners and cli-table3 for structured data display @@ -136,7 +137,7 @@ export class Logger { /** * Create a table for displaying data */ - table(options?: any): any { + table(options?: ConstructorParameters[0]): InstanceType { return new Table( options || { head: [], @@ -175,7 +176,11 @@ export class Logger { /** * Display a data table with headers */ - displayTable(headers: string[], rows: (string | number | boolean)[][], options?: any): void { + displayTable( + headers: string[], + rows: (string | number | boolean)[][], + options?: ConstructorParameters[0] + ): void { const table = this.table({ head: headers.map(h => chalk.cyan(h)), ...options, diff --git a/tests/__mocks__/@solana/web3.js.ts b/tests/__mocks__/@solana/web3.js.ts new file mode 100644 index 0000000..52e75c9 --- /dev/null +++ b/tests/__mocks__/@solana/web3.js.ts @@ -0,0 +1,3 @@ +// Pass-through mock — re-exports real @solana/web3.js so tests work by default. +// Call jest.mock('@solana/web3.js', () => ({ ... })) in individual tests to override. +module.exports = jest.requireActual('@solana/web3.js'); diff --git a/tests/__mocks__/evm-client-factory.mock.ts b/tests/__mocks__/evm-client-factory.mock.ts new file mode 100644 index 0000000..4d2c548 --- /dev/null +++ b/tests/__mocks__/evm-client-factory.mock.ts @@ -0,0 +1,22 @@ +/** + * Mock EVM client factory for unit/integration tests. + * Returns stub PublicClient and WalletClient that avoid live RPC connections. + */ + +import type { EvmClientFactory } from '@/blockchain/evm/evm-client-factory'; + +export const mockEvmPublicClient = { + getBalance: jest.fn().mockResolvedValue(0n), + readContract: jest.fn().mockResolvedValue(0n), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ status: 'success', logs: [] }), +} as unknown as ReturnType; + +export const mockEvmWalletClient = { + writeContract: jest.fn().mockResolvedValue('0xmockapprovetxhash'), + sendTransaction: jest.fn().mockResolvedValue('0xmockpublishtxhash'), +} as unknown as ReturnType; + +export const createMockEvmClientFactory = (): EvmClientFactory => ({ + createPublicClient: jest.fn().mockReturnValue(mockEvmPublicClient), + createWalletClient: jest.fn().mockReturnValue(mockEvmWalletClient), +}); diff --git a/tests/__mocks__/ora.ts b/tests/__mocks__/ora.ts new file mode 100644 index 0000000..6c0517a --- /dev/null +++ b/tests/__mocks__/ora.ts @@ -0,0 +1,12 @@ +const spinner = { + start: jest.fn().mockReturnThis(), + stop: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + warn: jest.fn().mockReturnThis(), + info: jest.fn().mockReturnThis(), + text: '', +}; + +const ora = jest.fn(() => spinner); +export default ora; diff --git a/tests/__mocks__/svm-client-factory.mock.ts b/tests/__mocks__/svm-client-factory.mock.ts new file mode 100644 index 0000000..9bc252d --- /dev/null +++ b/tests/__mocks__/svm-client-factory.mock.ts @@ -0,0 +1,21 @@ +/** + * Mock SVM client factory for unit/integration tests. + * Returns a stub Solana Connection that avoids live RPC connections. + */ + +import type { SvmClientFactory } from '@/blockchain/svm/svm-client-factory'; + +export const mockSolanaConnection = { + getBalance: jest.fn().mockResolvedValue(0), + getLatestBlockhash: jest.fn().mockResolvedValue({ + blockhash: 'mockblockhash', + lastValidBlockHeight: 1000, + }), + sendRawTransaction: jest.fn().mockResolvedValue('mocktxsignature'), + confirmTransaction: jest.fn().mockResolvedValue({ value: { err: null } }), + getAccountInfo: jest.fn().mockResolvedValue(null), +} as unknown as ReturnType; + +export const createMockSvmClientFactory = (): SvmClientFactory => ({ + createConnection: jest.fn().mockReturnValue(mockSolanaConnection), +}); diff --git a/tests/__mocks__/tronweb.ts b/tests/__mocks__/tronweb.ts new file mode 100644 index 0000000..3277314 --- /dev/null +++ b/tests/__mocks__/tronweb.ts @@ -0,0 +1,3 @@ +// Pass-through mock — re-exports real tronweb so tests work by default. +// Call jest.mock('tronweb', () => ({ ... })) in individual tests to override. +module.exports = jest.requireActual('tronweb'); diff --git a/tests/__mocks__/tvm-client-factory.mock.ts b/tests/__mocks__/tvm-client-factory.mock.ts new file mode 100644 index 0000000..24e3375 --- /dev/null +++ b/tests/__mocks__/tvm-client-factory.mock.ts @@ -0,0 +1,28 @@ +/** + * Mock TVM client factory for unit/integration tests. + * Returns a stub TronWeb instance that avoids live RPC connections. + */ + +import type { TvmClientFactory } from '@/blockchain/tvm/tvm-client-factory'; + +export const mockTronWeb = { + setPrivateKey: jest.fn(), + address: { + fromPrivateKey: jest.fn().mockReturnValue('TMockTronAddress123456789012345678'), + }, + trx: { + getBalance: jest.fn().mockResolvedValue(0), + getTransactionInfo: jest.fn().mockResolvedValue(null), + }, + contract: jest.fn().mockReturnValue({ + approve: jest.fn().mockReturnValue({ send: jest.fn().mockResolvedValue('mockapprovaltxid') }), + balanceOf: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue(0n) }), + publishAndFund: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue('mockpublishtxid'), + }), + }), +} as unknown as ReturnType; + +export const createMockTvmClientFactory = (): TvmClientFactory => ({ + createClient: jest.fn().mockReturnValue(mockTronWeb), +}); diff --git a/tests/__mocks__/viem.ts b/tests/__mocks__/viem.ts new file mode 100644 index 0000000..fc92081 --- /dev/null +++ b/tests/__mocks__/viem.ts @@ -0,0 +1,3 @@ +// Pass-through mock — re-exports real viem so tests work by default. +// Call jest.mock('viem', () => ({ ... })) in individual tests to override. +module.exports = jest.requireActual('viem'); diff --git a/tests/blockchain/.gitkeep b/tests/blockchain/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/blockchain/evm-publisher.integration.test.ts b/tests/blockchain/evm-publisher.integration.test.ts new file mode 100644 index 0000000..ca0ee66 --- /dev/null +++ b/tests/blockchain/evm-publisher.integration.test.ts @@ -0,0 +1,274 @@ +/** + * Integration tests for EvmPublisher using mocked viem clients. + * + * Verifies that EvmPublisher correctly delegates to its injected client factory, + * performs the right on-chain reads/writes, and handles success/revert outcomes — + * all without requiring a live RPC endpoint. + */ + +import { encodeFunctionData, getAddress, maxUint256 } from 'viem'; + +import { EvmPublisher } from '@/blockchain/evm/evm.publisher'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { portalAbi } from '@/commons/abis/portal.abi'; +import { KeyHandle } from '@/shared/security'; +import type { Intent } from '@/shared/types'; +import { ChainType } from '@/shared/types'; + +import { + createMockEvmClientFactory, + mockEvmPublicClient, + mockEvmWalletClient, +} from '../__mocks__/evm-client-factory.mock'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** Hardhat/Anvil account 0 — deterministic test private key. */ +const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; +/** Address derived from TEST_PRIVATE_KEY. */ +const SENDER_ADDR = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + +const PORTAL_ADDR_EVM = '0x399Dbd5DF04f83103F77A58cBa2B7c4d3cdede97'; // Base mainnet portal +const CREATOR_ADDR_EVM = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; // vitalik.eth +const PROVER_ADDR_EVM = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC contract (valid EVM addr) +const TOKEN_ADDR_EVM = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base + +const SOURCE_CHAIN_ID = 1n; // Ethereum (production env) +const DEST_CHAIN_ID = 10n; // Optimism (production env) + +// Minimal mocks for the NestJS DI dependencies that EvmPublisher requires +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockRegistry: any = { isRegistered: (_id: bigint) => true }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockChains: any = { + findChainById: (id: bigint) => + id === DEST_CHAIN_ID + ? { + id: DEST_CHAIN_ID, + name: 'Optimism', + type: 'EVM', + env: 'production', + rpcUrl: 'https://mainnet.optimism.io', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } + : undefined, +}; + +const portalUniversal = AddressNormalizer.normalize(PORTAL_ADDR_EVM, ChainType.EVM); +const creatorUniversal = AddressNormalizer.normalize(CREATOR_ADDR_EVM, ChainType.EVM); +const proverUniversal = AddressNormalizer.normalize(PROVER_ADDR_EVM, ChainType.EVM); +const tokenUniversal = AddressNormalizer.normalize(TOKEN_ADDR_EVM, ChainType.EVM); + +function makeReward(overrides: Partial = {}): Intent['reward'] { + return { + deadline: 9_999_999_999n, + creator: creatorUniversal, + prover: proverUniversal, + nativeAmount: 0n, + tokens: [], + ...overrides, + }; +} + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('EvmPublisher (integration — mocked clients)', () => { + let publisher: EvmPublisher; + + beforeEach(() => { + // Reset call counts and clear Once queues; keep default mockResolvedValue implementations. + jest.clearAllMocks(); + // Re-establish defaults explicitly so each test starts from a known state. + (mockEvmPublicClient.getBalance as jest.Mock).mockResolvedValue(0n); + (mockEvmPublicClient.readContract as jest.Mock).mockResolvedValue(0n); + (mockEvmPublicClient.waitForTransactionReceipt as jest.Mock).mockResolvedValue({ + status: 'success', + logs: [], + }); + (mockEvmWalletClient.writeContract as jest.Mock).mockResolvedValue('0xmockapprovetxhash'); + (mockEvmWalletClient.sendTransaction as jest.Mock).mockResolvedValue('0xmockpublishtxhash'); + + publisher = new EvmPublisher( + 'https://rpc.example.com', + mockRegistry, + mockChains, + createMockEvmClientFactory() + ); + }); + + // ── getBalance() ───────────────────────────────────────────────────────────── + + describe('getBalance()', () => { + it('returns the mocked native balance', async () => { + const balance = 5_000_000_000_000_000_000n; // 5 ETH in wei + (mockEvmPublicClient.getBalance as jest.Mock).mockResolvedValue(balance); + + const result = await publisher.getBalance(SENDER_ADDR); + + expect(result).toBe(balance); + expect(mockEvmPublicClient.getBalance).toHaveBeenCalledWith({ address: SENDER_ADDR }); + }); + }); + + // ── validate() ──────────────────────────────────────────────────────────────── + + describe('validate()', () => { + it('returns valid:true when native and token balances are sufficient', async () => { + const reward = makeReward({ + nativeAmount: 1n, + tokens: [{ token: tokenUniversal, amount: 100n }], + }); + (mockEvmPublicClient.getBalance as jest.Mock).mockResolvedValue(10n); // > 1n + (mockEvmPublicClient.readContract as jest.Mock).mockResolvedValue(200n); // > 100n + + const result = await publisher.validate(reward, SENDER_ADDR); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns valid:false with error when native balance is insufficient', async () => { + const reward = makeReward({ nativeAmount: 1_000_000_000_000_000_000n }); // requires 1 ETH + (mockEvmPublicClient.getBalance as jest.Mock).mockResolvedValue(0n); // has nothing + + const result = await publisher.validate(reward, SENDER_ADDR); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatch(/Insufficient native balance/); + }); + + it('returns valid:false with error when token balance is insufficient', async () => { + const reward = makeReward({ tokens: [{ token: tokenUniversal, amount: 500n }] }); + (mockEvmPublicClient.readContract as jest.Mock).mockResolvedValue(10n); // 10 < 500 + + const result = await publisher.validate(reward, SENDER_ADDR); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatch(/Insufficient token balance/); + }); + }); + + // ── publish() — token approval ──────────────────────────────────────────────── + + describe('publish() — token approval', () => { + it('skips approval when allowance is already sufficient', async () => { + const reward = makeReward({ tokens: [{ token: tokenUniversal, amount: 100n }] }); + + // balanceOf → 200 (sufficient); allowance → 200 (sufficient — skip approval) + (mockEvmPublicClient.readContract as jest.Mock) + .mockResolvedValueOnce(200n) + .mockResolvedValueOnce(200n); + (mockEvmPublicClient.waitForTransactionReceipt as jest.Mock).mockResolvedValue({ + status: 'reverted', + logs: [], + }); + + await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + reward, + '0x', + new KeyHandle(TEST_PRIVATE_KEY), + portalUniversal + ); + + expect(mockEvmWalletClient.writeContract).not.toHaveBeenCalled(); + }); + + it('sends approval transaction when allowance is insufficient', async () => { + const reward = makeReward({ tokens: [{ token: tokenUniversal, amount: 100n }] }); + + // balanceOf → 200 (sufficient); allowance → 0 (must approve) + (mockEvmPublicClient.readContract as jest.Mock) + .mockResolvedValueOnce(200n) + .mockResolvedValueOnce(0n); + (mockEvmPublicClient.waitForTransactionReceipt as jest.Mock) + .mockResolvedValueOnce({ status: 'success', logs: [] }) // approval receipt + .mockResolvedValueOnce({ status: 'reverted', logs: [] }); // main tx receipt + + await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + reward, + '0x', + new KeyHandle(TEST_PRIVATE_KEY), + portalUniversal + ); + + expect(mockEvmWalletClient.writeContract).toHaveBeenCalledTimes(1); + expect(mockEvmWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: getAddress(TOKEN_ADDR_EVM), + functionName: 'approve', + args: [getAddress(PORTAL_ADDR_EVM), maxUint256], + }) + ); + }); + }); + + // ── publish() — portal contract call ───────────────────────────────────────── + + describe('publish() — portal contract call', () => { + it('calls portal sendTransaction with correctly encoded publishAndFund data', async () => { + const reward = makeReward(); + const encodedRoute = '0xdeadbeef'; + (mockEvmPublicClient.waitForTransactionReceipt as jest.Mock).mockResolvedValue({ + status: 'reverted', + logs: [], + }); + + await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + reward, + encodedRoute, + new KeyHandle(TEST_PRIVATE_KEY), + portalUniversal + ); + + const expectedEvmReward = { + deadline: reward.deadline, + nativeAmount: 0n, + creator: getAddress(CREATOR_ADDR_EVM), + prover: getAddress(PROVER_ADDR_EVM), + tokens: [], + }; + const expectedData = encodeFunctionData({ + abi: portalAbi, + functionName: 'publishAndFund', + args: [DEST_CHAIN_ID, encodedRoute as `0x${string}`, expectedEvmReward, false], + }); + + expect(mockEvmWalletClient.sendTransaction).toHaveBeenCalledTimes(1); + expect(mockEvmWalletClient.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: getAddress(PORTAL_ADDR_EVM), + data: expectedData, + value: 0n, + }) + ); + }); + + it('returns { success: false } when transaction reverts', async () => { + const reward = makeReward(); + (mockEvmPublicClient.waitForTransactionReceipt as jest.Mock).mockResolvedValue({ + status: 'reverted', + logs: [], + }); + + const result = await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + reward, + '0x', + new KeyHandle(TEST_PRIVATE_KEY), + portalUniversal + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('Transaction failed'); + }); + }); +}); diff --git a/tests/core/utils/address-normalizer.test.ts b/tests/core/utils/address-normalizer.test.ts index 7e99621..c9198f0 100644 --- a/tests/core/utils/address-normalizer.test.ts +++ b/tests/core/utils/address-normalizer.test.ts @@ -1,124 +1,168 @@ /** * Tests for AddressNormalizer utility + * + * Covers EVM, TVM, and SVM normalization / denormalization round-trips, + * input validation, edge cases, and unsupported-chain error handling. */ -import { ChainType } from '@/core/interfaces/intent'; -import { BlockchainAddress } from '@/core/types/blockchain-addresses'; -import { UniversalAddress } from '@/core/types/universal-address'; -import { AddressNormalizer } from '@/core/utils/address-normalizer'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { ErrorCode, RoutesCliError } from '@/shared/errors'; +import { BlockchainAddress, ChainType, UniversalAddress } from '@/shared/types'; describe('AddressNormalizer', () => { - describe('normalize', () => { - it('should normalize EVM address to Universal Address', () => { - const evmAddress = '0x1234567890123456789012345678901234567890'; - const result = AddressNormalizer.normalize(evmAddress, ChainType.EVM); - - expect(result).toBe('0x0000000000000000000000001234567890123456789012345678901234567890'); - expect(result.length).toBe(66); // 0x + 64 hex characters + // ── EVM ────────────────────────────────────────────────────────────────────── + describe('EVM addresses', () => { + // vitalik.eth — widely documented EIP-55 checksummed address + const EVM_CHECKSUMMED = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + const EVM_LOWERCASE = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; + + it('normalizes a checksummed EVM address to universal format (0x + 64 hex chars)', () => { + const result = AddressNormalizer.normalize(EVM_CHECKSUMMED, ChainType.EVM); + expect(result).toMatch(/^0x[0-9a-fA-F]{64}$/); + expect(result).toHaveLength(66); }); - it('should normalize TVM address to Universal Address', () => { - const tvmAddress = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; // Valid USDT Tron address - const result = AddressNormalizer.normalize(tvmAddress, ChainType.TVM); - - expect(result).toMatch(/^0x[0-9a-f]{64}$/i); - expect(result.length).toBe(66); + it('normalizes a lowercase EVM address to the same universal address as its checksummed form', () => { + // Both representations refer to the same account — universal must be identical + const fromChecksummed = AddressNormalizer.normalize(EVM_CHECKSUMMED, ChainType.EVM); + const fromLowercase = AddressNormalizer.normalize(EVM_LOWERCASE, ChainType.EVM); + expect(fromChecksummed.toLowerCase()).toBe(fromLowercase.toLowerCase()); }); - it('should normalize SVM address to Universal Address', () => { - const svmAddress = '11111111111111111111111111111112' as BlockchainAddress; // System Program - const result = AddressNormalizer.normalize(svmAddress, ChainType.SVM); - - expect(result).toMatch(/^0x[0-9a-f]{64}$/i); - expect(result.length).toBe(66); + it('throws RoutesCliError with INVALID_ADDRESS for a malformed EVM address', () => { + const bad = '0x123' as BlockchainAddress; // Too short + expect(() => AddressNormalizer.normalize(bad, ChainType.EVM)).toThrow(RoutesCliError); + expect(() => AddressNormalizer.normalize(bad, ChainType.EVM)).toThrow( + expect.objectContaining({ code: ErrorCode.INVALID_ADDRESS }) + ); }); - it('should handle already normalized Universal Address', () => { - const evmAddress = '0x1234567890123456789012345678901234567890'; // Regular EVM address - const result = AddressNormalizer.normalize(evmAddress, ChainType.EVM); - - expect(result).toBe('0x0000000000000000000000001234567890123456789012345678901234567890'); + it('handles the EVM zero-address edge case', () => { + const ZERO = '0x0000000000000000000000000000000000000000' as BlockchainAddress; + const result = AddressNormalizer.normalize(ZERO, ChainType.EVM); + expect(result).toMatch(/^0x[0-9a-fA-F]{64}$/); + expect(result).toHaveLength(66); }); - it('should throw error for invalid EVM address', () => { - const invalidAddress = '0x123'; // Too short + it('denormalizes a universal address back to an EVM address', () => { + const universal = AddressNormalizer.normalize(EVM_CHECKSUMMED, ChainType.EVM); + const result = AddressNormalizer.denormalize(universal, ChainType.EVM); + expect(result).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); - expect(() => { - AddressNormalizer.normalize(invalidAddress, ChainType.EVM); - }).toThrow(); + it('round-trips EVM: normalize → denormalize returns the original address', () => { + const universal = AddressNormalizer.normalize(EVM_CHECKSUMMED, ChainType.EVM); + const back = AddressNormalizer.denormalize(universal, ChainType.EVM); + expect(back.toLowerCase()).toBe(EVM_LOWERCASE); }); }); - describe('denormalize', () => { - it('should denormalize Universal Address to EVM address', () => { - const universalAddress = - '0x0000000000000000000000001234567890123456789012345678901234567890' as UniversalAddress; - const result = AddressNormalizer.denormalize(universalAddress, ChainType.EVM); + // ── TVM ────────────────────────────────────────────────────────────────────── + describe('TVM addresses', () => { + // USDT contract on Tron — well-known address with verifiable hex equivalent + const TVM_BASE58 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + // Same address in Tron hex format (0x41 prefix, 21 bytes = 42 hex chars) + const TVM_HEX = '0x41a614f803b6fd780986a42c78ec9c7f77e6ded13c'; - expect(result).toBe('0x1234567890123456789012345678901234567890'); + it('normalizes a base58 Tron address to universal format', () => { + const result = AddressNormalizer.normalize(TVM_BASE58, ChainType.TVM); + expect(result).toMatch(/^0x[0-9a-f]{64}$/i); + expect(result).toHaveLength(66); }); - it('should denormalize Universal Address to TVM address', () => { - // This test uses a known TVM address conversion - const universalAddress = - '0x000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c' as UniversalAddress; - const result = AddressNormalizer.denormalize(universalAddress, ChainType.TVM); - - expect(result).toMatch(/^T[A-Za-z0-9]{33}$/); // TVM address format + it('normalizes a hex Tron address (0x41...) to the same universal address as its base58 form', () => { + const fromBase58 = AddressNormalizer.normalize(TVM_BASE58, ChainType.TVM); + const fromHex = AddressNormalizer.normalize(TVM_HEX, ChainType.TVM); + expect(fromHex).toBe(fromBase58); }); - it('should denormalize Universal Address to SVM address', () => { - const universalAddress = - '0x0000000000000000000000000000000000000000000000000000000000000001' as UniversalAddress; - const result = AddressNormalizer.denormalize(universalAddress, ChainType.SVM); + it('throws RoutesCliError with INVALID_ADDRESS for an invalid TVM address', () => { + const bad = 'NOT_A_TRON_ADDRESS' as BlockchainAddress; + expect(() => AddressNormalizer.normalize(bad, ChainType.TVM)).toThrow(RoutesCliError); + expect(() => AddressNormalizer.normalize(bad, ChainType.TVM)).toThrow( + expect.objectContaining({ code: ErrorCode.INVALID_ADDRESS }) + ); + }); - expect(result).toMatch(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/); // Base58 format + it('round-trips TVM: base58 normalize → denormalize returns the original address', () => { + const universal = AddressNormalizer.normalize(TVM_BASE58, ChainType.TVM); + const back = AddressNormalizer.denormalize(universal, ChainType.TVM); + expect(back).toBe(TVM_BASE58); }); + }); - it('should throw error for invalid Universal Address format', () => { - const invalidAddress = 'invalid-address' as UniversalAddress; // Not hex format + // ── SVM ────────────────────────────────────────────────────────────────────── + describe('SVM addresses', () => { + // Wrapped SOL mint address — a well-known 32-byte Solana public key + const SVM_ADDR = 'So11111111111111111111111111111111111111112' as BlockchainAddress; - expect(() => { - AddressNormalizer.denormalize(invalidAddress, ChainType.EVM); - }).toThrow(); + it('normalizes a base58 Solana public key to universal format', () => { + const result = AddressNormalizer.normalize(SVM_ADDR, ChainType.SVM); + expect(result).toMatch(/^0x[0-9a-f]{64}$/i); + expect(result).toHaveLength(66); }); - }); - describe('convenience methods', () => { - const universalAddress = - '0x0000000000000000000000001234567890123456789012345678901234567890' as UniversalAddress; + it('throws RoutesCliError with INVALID_ADDRESS for an invalid base58 Solana address', () => { + // '0' and 'I' are not in the base58 alphabet — will fail SvmAddressSchema + const bad = '0InvalidSolanaAddress0000000000000000' as BlockchainAddress; + expect(() => AddressNormalizer.normalize(bad, ChainType.SVM)).toThrow(RoutesCliError); + expect(() => AddressNormalizer.normalize(bad, ChainType.SVM)).toThrow( + expect.objectContaining({ code: ErrorCode.INVALID_ADDRESS }) + ); + }); - it('should denormalize to EVM using convenience method', () => { - const result = AddressNormalizer.denormalizeToEvm(universalAddress); - expect(result).toBe('0x1234567890123456789012345678901234567890'); + it('round-trips SVM: base58 normalize → denormalize returns the original address', () => { + const universal = AddressNormalizer.normalize(SVM_ADDR, ChainType.SVM); + const back = AddressNormalizer.denormalize(universal, ChainType.SVM); + expect(back).toBe(SVM_ADDR); }); + }); - it('should denormalize to TVM using convenience method', () => { - const result = AddressNormalizer.denormalizeToTvm(universalAddress); - expect(result).toMatch(/^T[A-Za-z0-9]{33}$/); + // ── Unsupported chain type ──────────────────────────────────────────────────── + describe('unsupported chain type', () => { + const UNSUPPORTED = 99 as unknown as ChainType; + // A universal address produced by normalizing a known lowercase EVM address + const VALID_UNIVERSAL = + '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045' as UniversalAddress; + + it('throws RoutesCliError with UNSUPPORTED_CHAIN on normalize', () => { + expect(() => + AddressNormalizer.normalize('0xd8da6bf26964af9d7eed9e03e53415d37aa96045', UNSUPPORTED) + ).toThrow(RoutesCliError); + expect(() => + AddressNormalizer.normalize('0xd8da6bf26964af9d7eed9e03e53415d37aa96045', UNSUPPORTED) + ).toThrow(expect.objectContaining({ code: ErrorCode.UNSUPPORTED_CHAIN })); }); - it('should denormalize to SVM using convenience method', () => { - const result = AddressNormalizer.denormalizeToSvm(universalAddress); - expect(result).toMatch(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/); + it('throws RoutesCliError with UNSUPPORTED_CHAIN on denormalize', () => { + expect(() => AddressNormalizer.denormalize(VALID_UNIVERSAL, UNSUPPORTED)).toThrow( + RoutesCliError + ); + expect(() => AddressNormalizer.denormalize(VALID_UNIVERSAL, UNSUPPORTED)).toThrow( + expect.objectContaining({ code: ErrorCode.UNSUPPORTED_CHAIN }) + ); }); }); - describe('round-trip conversion', () => { - it('should maintain data integrity in EVM round-trip', () => { - const originalAddress = '0x1234567890123456789012345678901234567890'; - const normalized = AddressNormalizer.normalize(originalAddress, ChainType.EVM); - const denormalized = AddressNormalizer.denormalize(normalized, ChainType.EVM); + // ── Static convenience methods ──────────────────────────────────────────────── + describe('static convenience methods', () => { + // EVM-style universal address (zero-padded 20-byte address, digits only → no checksum ambiguity) + const UNIVERSAL = + '0x0000000000000000000000001234567890123456789012345678901234567890' as UniversalAddress; - expect(denormalized.toLowerCase()).toBe(originalAddress.toLowerCase()); + it('denormalizeToEvm returns a checksummed 20-byte EVM address', () => { + const result = AddressNormalizer.denormalizeToEvm(UNIVERSAL); + expect(result).toMatch(/^0x[0-9a-fA-F]{40}$/); }); - it('should maintain data integrity in SVM round-trip', () => { - const originalAddress = '11111111111111111111111111111112' as BlockchainAddress; // System Program - const normalized = AddressNormalizer.normalize(originalAddress, ChainType.SVM); - const denormalized = AddressNormalizer.denormalize(normalized, ChainType.SVM); + it('denormalizeToTvm returns a base58 Tron address starting with T', () => { + const result = AddressNormalizer.denormalizeToTvm(UNIVERSAL); + expect(result).toMatch(/^T[A-Za-z0-9]{33}$/); + }); - expect(denormalized).toBe(originalAddress); + it('denormalizeToSvm returns a base58-encoded Solana public key', () => { + const result = AddressNormalizer.denormalizeToSvm(UNIVERSAL); + expect(result).toMatch(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/); }); }); }); diff --git a/tests/core/utils/chain-detector.test.ts b/tests/core/utils/chain-detector.test.ts deleted file mode 100644 index 1f46df7..0000000 --- a/tests/core/utils/chain-detector.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Tests for ChainTypeDetector utility - */ - -import { ChainType } from '@/core/interfaces/intent'; -import { ChainTypeDetector } from '@/core/utils/chain-detector'; - -describe('ChainTypeDetector', () => { - describe('detect', () => { - it('should detect EVM chain types from chain IDs', () => { - const evmChainIds = [ - 1, // Ethereum mainnet - 10, // Optimism - 137, // Polygon - 8453, // Base - 42161, // Arbitrum - ]; - - evmChainIds.forEach(chainId => { - expect(ChainTypeDetector.detect(chainId)).toBe(ChainType.EVM); - }); - }); - - it('should detect TVM chain types from chain IDs', () => { - const tvmChainIds = [ - 728126428, // Tron mainnet - 2494104990, // Tron Shasta testnet - ]; - - tvmChainIds.forEach(chainId => { - expect(ChainTypeDetector.detect(chainId)).toBe(ChainType.TVM); - }); - }); - - it('should detect SVM chain types from chain IDs', () => { - const svmChainIds = [ - 1399811149, // Solana mainnet - 1399811150, // Solana devnet - 1399811151, // Solana testnet - ]; - - svmChainIds.forEach(chainId => { - expect(ChainTypeDetector.detect(chainId)).toBe(ChainType.SVM); - }); - }); - - it('should handle bigint chain identifiers', () => { - const chainId = BigInt(1); - expect(ChainTypeDetector.detect(chainId)).toBe(ChainType.EVM); - }); - - it('should throw error for string chain identifiers (deprecated)', () => { - expect(() => { - ChainTypeDetector.detect('ethereum'); - }).toThrow('String chain identifiers are deprecated'); - }); - - it('should throw error for unknown chain IDs', () => { - const unknownChainId = 5000000000; // Outside EVM range (> 2^32) - expect(() => { - ChainTypeDetector.detect(unknownChainId); - }).toThrow('Cannot determine chain type for chain ID'); - }); - }); - - describe('getAddressFormat', () => { - it('should return correct format for EVM', () => { - expect(ChainTypeDetector.getAddressFormat(ChainType.EVM)).toBe('hex (0x prefixed, 20 bytes)'); - }); - - it('should return correct format for TVM', () => { - expect(ChainTypeDetector.getAddressFormat(ChainType.TVM)).toBe('base58 (Tron format)'); - }); - - it('should return correct format for SVM', () => { - expect(ChainTypeDetector.getAddressFormat(ChainType.SVM)).toBe( - 'base58 (Solana format, 32 bytes)' - ); - }); - - it('should throw error for unknown chain type', () => { - expect(() => { - ChainTypeDetector.getAddressFormat('UNKNOWN' as ChainType); - }).toThrow('Unknown chain type'); - }); - }); - - describe('isValidAddressForChain', () => { - it('should validate EVM addresses', () => { - expect( - ChainTypeDetector.isValidAddressForChain( - '0x1234567890123456789012345678901234567890', - ChainType.EVM - ) - ).toBe(true); - expect(ChainTypeDetector.isValidAddressForChain('0x123', ChainType.EVM)).toBe(false); - expect( - ChainTypeDetector.isValidAddressForChain( - '1234567890123456789012345678901234567890', - ChainType.EVM - ) - ).toBe(false); - }); - - it('should validate TVM addresses', () => { - expect( - ChainTypeDetector.isValidAddressForChain( - 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', - ChainType.TVM - ) - ).toBe(true); - expect(ChainTypeDetector.isValidAddressForChain('TR7', ChainType.TVM)).toBe(false); - expect( - ChainTypeDetector.isValidAddressForChain( - '0x1234567890123456789012345678901234567890', - ChainType.TVM - ) - ).toBe(false); - }); - - it('should validate SVM addresses', () => { - expect( - ChainTypeDetector.isValidAddressForChain('11111111111111111111111111111112', ChainType.SVM) - ).toBe(true); - expect( - ChainTypeDetector.isValidAddressForChain( - 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - ChainType.SVM - ) - ).toBe(true); - expect(ChainTypeDetector.isValidAddressForChain('123', ChainType.SVM)).toBe(false); - expect( - ChainTypeDetector.isValidAddressForChain( - '0x1234567890123456789012345678901234567890', - ChainType.SVM - ) - ).toBe(false); - }); - }); -}); diff --git a/tests/core/utils/portal-encoder.test.ts b/tests/core/utils/portal-encoder.test.ts new file mode 100644 index 0000000..980d39f --- /dev/null +++ b/tests/core/utils/portal-encoder.test.ts @@ -0,0 +1,386 @@ +/** + * Tests for PortalEncoder utility + * + * Covers: + * - isRoute() type guard + * - EVM encode/decode round-trip for both Route and Reward + * - SVM Borsh encode/decode round-trip for both Route and Reward + * - Edge cases: empty token/call arrays, zero amounts, large BigInts + */ + +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { PortalEncoder } from '@/blockchain/utils/portal-encoder'; +import { BlockchainAddress, ChainType, Intent } from '@/shared/types'; + +// ── EVM fixtures ───────────────────────────────────────────────────────────── +const EVM_VITALIK = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as BlockchainAddress; +const EVM_USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as BlockchainAddress; +const EVM_WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as BlockchainAddress; + +const U_VITALIK = AddressNormalizer.normalize(EVM_VITALIK, ChainType.EVM); +const U_USDC = AddressNormalizer.normalize(EVM_USDC, ChainType.EVM); +const U_WETH = AddressNormalizer.normalize(EVM_WETH, ChainType.EVM); + +// ── SVM fixtures ───────────────────────────────────────────────────────────── +// Well-known Solana public keys (Wrapped SOL and SPL Token program) +const SVM_WSOL = 'So11111111111111111111111111111111111111112' as BlockchainAddress; +const SVM_TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as BlockchainAddress; +const SVM_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as BlockchainAddress; + +const U_SVM_WSOL = AddressNormalizer.normalize(SVM_WSOL, ChainType.SVM); +const U_SVM_TOKEN_PROGRAM = AddressNormalizer.normalize(SVM_TOKEN_PROGRAM, ChainType.SVM); +const U_SVM_USDC = AddressNormalizer.normalize(SVM_USDC, ChainType.SVM); + +// ── Shared route/reward builders ────────────────────────────────────────────── + +function buildEvmRoute(overrides: Partial = {}): Intent['route'] { + return { + salt: '0x0000000000000000000000000000000000000000000000000000000000000001', + deadline: 9_999_999_999n, + portal: U_VITALIK, + nativeAmount: 0n, + tokens: [{ token: U_USDC, amount: 1_000_000n }], + calls: [{ target: U_WETH, data: '0x', value: 0n }], + ...overrides, + }; +} + +function buildEvmReward(overrides: Partial = {}): Intent['reward'] { + return { + deadline: 9_999_999_999n, + creator: U_VITALIK, + prover: U_USDC, + nativeAmount: 0n, + tokens: [{ token: U_WETH, amount: 500_000n }], + ...overrides, + }; +} + +function buildSvmRoute(overrides: Partial = {}): Intent['route'] { + return { + salt: '0x0000000000000000000000000000000000000000000000000000000000000002', + deadline: 9_999_999_999n, + portal: U_SVM_WSOL, + nativeAmount: 0n, + tokens: [{ token: U_SVM_USDC, amount: 1_000_000n }], + calls: [], + ...overrides, + }; +} + +function buildSvmReward(overrides: Partial = {}): Intent['reward'] { + return { + deadline: 9_999_999_999n, + creator: U_SVM_WSOL, + prover: U_SVM_TOKEN_PROGRAM, + nativeAmount: 0n, + tokens: [{ token: U_SVM_USDC, amount: 1_000_000n }], + ...overrides, + }; +} + +// ── isRoute() type guard ────────────────────────────────────────────────────── + +describe('PortalEncoder.isRoute()', () => { + it('returns true for a Route object (has salt, portal, calls)', () => { + const route = buildEvmRoute(); + expect(PortalEncoder.isRoute(route)).toBe(true); + }); + + it('returns false for a Reward object (no salt, portal, calls)', () => { + const reward = buildEvmReward(); + expect(PortalEncoder.isRoute(reward)).toBe(false); + }); + + it('returns false for a reward that has no portal field', () => { + // Rewards have creator/prover but no portal/salt/calls + const reward: Intent['reward'] = { + deadline: 1000n, + creator: U_VITALIK, + prover: U_USDC, + nativeAmount: 0n, + tokens: [], + }; + expect(PortalEncoder.isRoute(reward)).toBe(false); + }); +}); + +// ── EVM encode / decode round-trips ────────────────────────────────────────── + +describe('PortalEncoder EVM route', () => { + it('encode() returns a hex string starting with 0x', () => { + const route = buildEvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + expect(encoded).toMatch(/^0x/); + }); + + it('encode() returns a non-empty hex string', () => { + const route = buildEvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + expect(encoded.length).toBeGreaterThan(2); + }); + + it('decode(encode(route)) round-trips the portal address', () => { + const route = buildEvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.portal.toLowerCase()).toBe(route.portal.toLowerCase()); + }); + + it('decode(encode(route)) preserves deadline', () => { + const route = buildEvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.deadline).toBe(route.deadline); + }); + + it('decode(encode(route)) preserves token amount', () => { + const route = buildEvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.tokens[0].amount).toBe(1_000_000n); + }); + + it('decode(encode(route)) preserves token address', () => { + const route = buildEvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.tokens[0].token.toLowerCase()).toBe(route.tokens[0].token.toLowerCase()); + }); + + it('decode(encode(route)) preserves call target', () => { + const route = buildEvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.calls[0].target.toLowerCase()).toBe(route.calls[0].target.toLowerCase()); + }); + + it('preserves multiple tokens across encode/decode', () => { + const route = buildEvmRoute({ + tokens: [ + { token: U_USDC, amount: 1_000_000n }, + { token: U_WETH, amount: 2_000_000n }, + ], + }); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.tokens).toHaveLength(2); + expect(decoded.tokens[1].amount).toBe(2_000_000n); + }); + + it('handles empty tokens array', () => { + const route = buildEvmRoute({ tokens: [], calls: [] }); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.tokens).toHaveLength(0); + expect(decoded.calls).toHaveLength(0); + }); + + it('preserves a large BigInt amount without truncation', () => { + const LARGE = 999_999_999_999_999_999n; + const route = buildEvmRoute({ tokens: [{ token: U_USDC, amount: LARGE }] }); + const encoded = PortalEncoder.encode(route, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'route'); + + expect(decoded.tokens[0].amount).toBe(LARGE); + }); +}); + +describe('PortalEncoder EVM reward', () => { + it('encode() returns a hex string', () => { + const reward = buildEvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + expect(encoded).toMatch(/^0x/); + }); + + it('decode(encode(reward)) round-trips creator address', () => { + const reward = buildEvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'reward'); + + expect(decoded.creator.toLowerCase()).toBe(reward.creator.toLowerCase()); + }); + + it('decode(encode(reward)) round-trips prover address', () => { + const reward = buildEvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'reward'); + + expect(decoded.prover.toLowerCase()).toBe(reward.prover.toLowerCase()); + }); + + it('decode(encode(reward)) preserves deadline', () => { + const reward = buildEvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'reward'); + + expect(decoded.deadline).toBe(9_999_999_999n); + }); + + it('decode(encode(reward)) preserves nativeAmount', () => { + const reward = buildEvmReward({ nativeAmount: 100n }); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'reward'); + + expect(decoded.nativeAmount).toBe(100n); + }); + + it('decode(encode(reward)) preserves token amount', () => { + const reward = buildEvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'reward'); + + expect(decoded.tokens[0].amount).toBe(500_000n); + }); + + it('preserves multiple tokens in reward across encode/decode', () => { + const reward = buildEvmReward({ + tokens: [ + { token: U_USDC, amount: 1_000_000n }, + { token: U_WETH, amount: 2_000_000n }, + ], + }); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'reward'); + + expect(decoded.tokens).toHaveLength(2); + expect(decoded.tokens[1].amount).toBe(2_000_000n); + }); + + it('handles empty tokens array in reward', () => { + const reward = buildEvmReward({ tokens: [] }); + const encoded = PortalEncoder.encode(reward, ChainType.EVM); + const decoded = PortalEncoder.decode(encoded, ChainType.EVM, 'reward'); + + expect(decoded.tokens).toHaveLength(0); + }); +}); + +// ── TVM encode / decode (uses same ABI encoding as EVM) ────────────────────── + +describe('PortalEncoder TVM', () => { + it('TVM encode produces the same bytes as EVM for a route', () => { + const route = buildEvmRoute(); + const evmEncoded = PortalEncoder.encode(route, ChainType.EVM); + const tvmEncoded = PortalEncoder.encode(route, ChainType.TVM); + // Both use encodeAbiParameters — bytes should be identical + expect(tvmEncoded).toBe(evmEncoded); + }); +}); + +// ── SVM Borsh encode / decode round-trips ───────────────────────────────────── + +describe('PortalEncoder SVM route', () => { + it('encode() returns a hex string for a valid SVM route', () => { + const route = buildSvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.SVM); + expect(encoded).toMatch(/^0x/); + expect(encoded.length).toBeGreaterThan(2); + }); + + it('decode(encode(route)) round-trips the portal address', () => { + const route = buildSvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'route'); + + expect(decoded.portal.toLowerCase()).toBe(route.portal.toLowerCase()); + }); + + it('decode(encode(route)) preserves deadline', () => { + const route = buildSvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'route'); + + expect(decoded.deadline).toBe(route.deadline); + }); + + it('decode(encode(route)) preserves token amount', () => { + const route = buildSvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'route'); + + expect(decoded.tokens[0].amount).toBe(1_000_000n); + }); + + it('decode(encode(route)) preserves token address', () => { + const route = buildSvmRoute(); + const encoded = PortalEncoder.encode(route, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'route'); + + expect(decoded.tokens[0].token.toLowerCase()).toBe(route.tokens[0].token.toLowerCase()); + }); + + it('handles empty tokens array for SVM route', () => { + const route = buildSvmRoute({ tokens: [], calls: [] }); + const encoded = PortalEncoder.encode(route, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'route'); + + expect(decoded.tokens).toHaveLength(0); + expect(decoded.calls).toHaveLength(0); + }); +}); + +describe('PortalEncoder SVM reward', () => { + it('encode() returns a hex string for a valid SVM reward', () => { + const reward = buildSvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.SVM); + expect(encoded).toMatch(/^0x/); + expect(encoded.length).toBeGreaterThan(2); + }); + + it('decode(encode(reward)) round-trips creator address', () => { + const reward = buildSvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'reward'); + + expect(decoded.creator.toLowerCase()).toBe(reward.creator.toLowerCase()); + }); + + it('decode(encode(reward)) preserves deadline', () => { + const reward = buildSvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'reward'); + + expect(decoded.deadline).toBe(reward.deadline); + }); + + it('decode(encode(reward)) preserves token amount', () => { + const reward = buildSvmReward(); + const encoded = PortalEncoder.encode(reward, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'reward'); + + expect(decoded.tokens[0].amount).toBe(1_000_000n); + }); + + it('handles empty tokens array in SVM reward', () => { + const reward = buildSvmReward({ tokens: [] }); + const encoded = PortalEncoder.encode(reward, ChainType.SVM); + const decoded = PortalEncoder.decode(encoded, ChainType.SVM, 'reward'); + + expect(decoded.tokens).toHaveLength(0); + }); +}); + +// ── Unsupported chain type ──────────────────────────────────────────────────── + +describe('PortalEncoder unsupported chain type', () => { + it('encode() throws for an unsupported chain type', () => { + const route = buildEvmRoute(); + const UNSUPPORTED = 99 as unknown as ChainType; + expect(() => PortalEncoder.encode(route, UNSUPPORTED)).toThrow(/unsupported chain type/i); + }); + + it('decode() throws for an unsupported chain type', () => { + const UNSUPPORTED = 99 as unknown as ChainType; + expect(() => PortalEncoder.decode('0x1234', UNSUPPORTED, 'route')).toThrow( + /unsupported chain type/i + ); + }); +}); diff --git a/tests/core/utils/quote.test.ts b/tests/core/utils/quote.test.ts deleted file mode 100644 index a2a06e5..0000000 --- a/tests/core/utils/quote.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Tests for quote service and solver-v2 integration - */ - -import { getQuote } from '@/core/utils/quote'; - -// Mock fetch globally -global.fetch = jest.fn(); - -describe('Quote Service', () => { - beforeEach(() => { - jest.clearAllMocks(); - // Clear environment variables - delete process.env.SOLVER_URL; - delete process.env.QUOTES_API_URL; - delete process.env.QUOTES_PREPROD; - }); - - describe('URL Selection', () => { - it('should use solver-v2 URL when SOLVER_URL is set', async () => { - process.env.SOLVER_URL = 'https://solver.example.com'; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - quoteResponses: [ - { - sourceChainID: 1, - destinationChainID: 10, - sourceToken: '0x1234567890123456789012345678901234567890', - destinationToken: '0x1234567890123456789012345678901234567890', - sourceAmount: '1000000000000000000', - destinationAmount: '990000000000000000', - funder: '0x1234567890123456789012345678901234567890', - refundRecipient: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - encodedRoute: '0x', - fees: [], - deadline: 1735689600, - estimatedFulfillTimeSec: 30, - }, - ], - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }), - }); - - await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://solver.example.com/api/v2/quote/reverse', - expect.any(Object) - ); - }); - - it('should use preprod quote service when QUOTES_PREPROD is set', async () => { - process.env.QUOTES_PREPROD = '1'; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - quoteResponse: { - sourceChainID: 1, - destinationChainID: 10, - sourceToken: '0x1234567890123456789012345678901234567890', - destinationToken: '0x1234567890123456789012345678901234567890', - sourceAmount: '1000000000000000000', - destinationAmount: '990000000000000000', - funder: '0x1234567890123456789012345678901234567890', - refundRecipient: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - encodedRoute: '0x', - fees: [], - deadline: 1735689600, - }, - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }, - }), - }); - - await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://quotes-preprod.eco.com/api/v3/quotes/single', - expect.any(Object) - ); - }); - - it('should use production quote service by default', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - quoteResponse: {}, - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }, - }), - }); - - await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://quotes.eco.com/api/v3/quotes/single', - expect.any(Object) - ); - }); - - it('should remove trailing slash from SOLVER_URL', async () => { - process.env.SOLVER_URL = 'https://solver.example.com/'; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - quoteResponses: [{}], - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }), - }); - - await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://solver.example.com/api/v2/quote/reverse', - expect.any(Object) - ); - }); - }); - - describe('Request Format', () => { - it('should send chain IDs as strings for solver-v2', async () => { - process.env.SOLVER_URL = 'https://solver.example.com'; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - quoteResponses: [{}], - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }), - }); - - await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - const callArgs = (global.fetch as jest.Mock).mock.calls[0]; - const requestBody = JSON.parse(callArgs[1].body); - - expect(typeof requestBody.quoteRequest.sourceChainID).toBe('string'); - expect(typeof requestBody.quoteRequest.destinationChainID).toBe('string'); - }); - - it('should send chain IDs as numbers for quote service', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - quoteResponse: {}, - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }, - }), - }); - - await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - const callArgs = (global.fetch as jest.Mock).mock.calls[0]; - const requestBody = JSON.parse(callArgs[1].body); - - expect(typeof requestBody.quoteRequest.sourceChainID).toBe('number'); - expect(typeof requestBody.quoteRequest.destinationChainID).toBe('number'); - }); - }); - - describe('Response Handling', () => { - it('should handle solver-v2 array response format', async () => { - process.env.SOLVER_URL = 'https://solver.example.com'; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - quoteResponses: [ - { - sourceChainID: 1, - destinationChainID: 10, - encodedRoute: '0xabcd', - estimatedFulfillTimeSec: 30, - }, - ], - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }), - }); - - const result = await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - expect(result.quoteResponse).toBeDefined(); - expect(result.quoteResponse?.encodedRoute).toBe('0xabcd'); - expect(result.quoteResponse?.estimatedFulfillTimeSec).toBe(30); - }); - - it('should handle quote service wrapped response format', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - quoteResponse: { - encodedRoute: '0xabcd', - }, - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }, - }), - }); - - const result = await getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }); - - expect(result.quoteResponse).toBeDefined(); - expect(result.quoteResponse?.encodedRoute).toBe('0xabcd'); - }); - - it('should throw error if solver-v2 returns empty quoteResponses', async () => { - process.env.SOLVER_URL = 'https://solver.example.com'; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - quoteResponses: [], - contracts: { - sourcePortal: '0x1234567890123456789012345678901234567890', - prover: '0x1234567890123456789012345678901234567890', - destinationPortal: '0x1234567890123456789012345678901234567890', - }, - }), - }); - - await expect( - getQuote({ - source: 1n, - destination: 10n, - amount: 1000000000000000000n, - funder: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - routeToken: '0x1234567890123456789012345678901234567890', - rewardToken: '0x1234567890123456789012345678901234567890', - }) - ).rejects.toThrow('Invalid solver-v2 response: no quotes returned'); - }); - }); -}); diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/docker-compose.e2e.yml b/tests/e2e/docker-compose.e2e.yml new file mode 100644 index 0000000..8857c3d --- /dev/null +++ b/tests/e2e/docker-compose.e2e.yml @@ -0,0 +1,21 @@ +services: + anvil: + image: ghcr.io/foundry-rs/foundry:latest + entrypoint: anvil + command: > + --fork-url ${FORK_RPC_URL} + --chain-id 8453 + --host 0.0.0.0 + --port 8545 + --silent + --block-time 2 + ports: + - "8545:8545" + healthcheck: + test: + - "CMD-SHELL" + - "cast block-number --rpc-url http://localhost:8545 > /dev/null 2>&1" + interval: 3s + timeout: 5s + retries: 20 + start_period: 5s diff --git a/tests/e2e/evm-publish.e2e.test.ts b/tests/e2e/evm-publish.e2e.test.ts new file mode 100644 index 0000000..95b96e1 --- /dev/null +++ b/tests/e2e/evm-publish.e2e.test.ts @@ -0,0 +1,174 @@ +import { createPublicClient, http, parseEventLogs, parseUnits } from 'viem'; +import { base } from 'viem/chains'; + +import { EvmPublisher } from '@/blockchain/evm/evm.publisher'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { PortalEncoder } from '@/blockchain/utils/portal-encoder'; +import { portalAbi } from '@/commons/abis/portal.abi'; +import { ChainType, KeyHandle } from '@/shared'; + +import { + ANVIL_RPC, + fundTestAccountWithUsdc, + getUsdcBalance, + PORTAL_ADDRESS, + TEST_ADDRESS, + TEST_PRIVATE_KEY, + USDC_ADDRESS, +} from './setup/anvil-helpers'; + +const SOURCE_CHAIN_ID = 8453n; // Base mainnet +const DEST_CHAIN_ID = 10n; // Optimism + +// Minimal fakes satisfying the DI contracts required by EvmPublisher +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockRegistry: any = { isRegistered: (_id: bigint) => true }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockChains: any = { + findChainById: (id: bigint) => { + if (id === SOURCE_CHAIN_ID || id === DEST_CHAIN_ID) { + return { id, name: id === SOURCE_CHAIN_ID ? 'Base' : 'Optimism', type: 'EVM' }; + } + return undefined; + }, +}; + +const universalCreator = AddressNormalizer.normalize(TEST_ADDRESS, ChainType.EVM); +const universalPortal = AddressNormalizer.normalize(PORTAL_ADDRESS, ChainType.EVM); +const universalUsdc = AddressNormalizer.normalize(USDC_ADDRESS, ChainType.EVM); + +function buildReward(deadlineOffsetSec = 3600): { + deadline: bigint; + nativeAmount: bigint; + creator: ReturnType; + prover: ReturnType; + tokens: Array<{ token: ReturnType; amount: bigint }>; +} { + return { + deadline: BigInt(Math.floor(Date.now() / 1000) + deadlineOffsetSec), + nativeAmount: 0n, + creator: universalCreator, + prover: universalCreator, // using self as prover for test simplicity + tokens: [{ token: universalUsdc, amount: parseUnits('5', 6) }], // 5 USDC + }; +} + +const encodedRoute = PortalEncoder.encode( + { + salt: '0x0000000000000000000000000000000000000000000000000000000000000001', + portal: universalPortal, + calls: [], + nativeAmount: 0n, + deadline: 0n, + tokens: [{ token: universalUsdc, amount: parseUnits('5', 6) }], + }, + ChainType.EVM +) as string; + +describe('EvmPublisher E2E — Base mainnet fork via Anvil', () => { + let publisher: EvmPublisher; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let publicClient: any; + + beforeAll(async () => { + publisher = new EvmPublisher(ANVIL_RPC, mockRegistry, mockChains); + publicClient = createPublicClient({ chain: base, transport: http(ANVIL_RPC) }); + + // Write 100 USDC directly into the test account storage on the fork + await fundTestAccountWithUsdc(100); + }); + + // ─── Happy path ───────────────────────────────────────────────────────────── + + it('publishes intent and emits IntentPublished event on-chain', async () => { + const reward = buildReward(); + const result = await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + reward, + encodedRoute, + new KeyHandle(TEST_PRIVATE_KEY), + universalPortal + ); + + expect(result.success).toBe(true); + expect(result.transactionHash).toMatch(/^0x[a-f0-9]{64}$/i); + expect(result.intentHash).toMatch(/^0x[a-f0-9]{64}$/i); + + // Verify the IntentPublished event was actually emitted on-chain + const receipt = await publicClient.getTransactionReceipt({ + hash: result.transactionHash as `0x${string}`, + }); + const [event] = parseEventLogs({ + abi: portalAbi, + eventName: 'IntentPublished', + logs: receipt.logs, + }); + expect(event).toBeDefined(); + expect(event.args.intentHash).toBe(result.intentHash); + }); + + it('USDC is deducted from test account after funding', async () => { + const balanceBefore = await getUsdcBalance(TEST_ADDRESS); + const reward = buildReward(7200); // different deadline = new intent hash + await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + reward, + encodedRoute, + new KeyHandle(TEST_PRIVATE_KEY), + universalPortal + ); + const balanceAfter = await getUsdcBalance(TEST_ADDRESS); + expect(balanceAfter).toBeLessThan(balanceBefore); + }); + + it('skips approval on second publish (maxUint256 allowance already set)', async () => { + // After the first test the portal already has maxUint256 allowance. + // This test measures that the second publish succeeds (no approval tx needed). + const reward = buildReward(10800); + const result = await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + reward, + encodedRoute, + new KeyHandle(TEST_PRIVATE_KEY), + universalPortal + ); + expect(result.success).toBe(true); + }); + + // ─── validate() against real chain ────────────────────────────────────────── + + it('validate() passes when USDC balance is sufficient', async () => { + const result = await publisher.validate(buildReward(), TEST_ADDRESS); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('validate() fails when USDC balance is insufficient', async () => { + const hugeReward = buildReward(); + hugeReward.tokens = [{ token: universalUsdc, amount: parseUnits('999999', 6) }]; + const result = await publisher.validate(hugeReward, TEST_ADDRESS); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/insufficient/i); + }); + + // ─── Error paths ───────────────────────────────────────────────────────────── + + it('returns { success: false } when portal address is wrong', async () => { + const badPortal = AddressNormalizer.normalize( + '0x0000000000000000000000000000000000000001', + ChainType.EVM + ); + const result = await publisher.publish( + SOURCE_CHAIN_ID, + DEST_CHAIN_ID, + buildReward(), + encodedRoute, + new KeyHandle(TEST_PRIVATE_KEY), + badPortal + ); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/e2e/setup/anvil-helpers.ts b/tests/e2e/setup/anvil-helpers.ts new file mode 100644 index 0000000..45b4720 --- /dev/null +++ b/tests/e2e/setup/anvil-helpers.ts @@ -0,0 +1,65 @@ +import { + createPublicClient, + encodeAbiParameters, + http, + keccak256, + parseAbiParameters, + parseUnits, +} from 'viem'; +import { base } from 'viem/chains'; + +export const ANVIL_RPC = 'http://localhost:8545'; + +// Anvil default test account #0 — pre-funded with 10 000 ETH by Anvil at fork startup +export const TEST_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const; +export const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const; + +// Base mainnet contract addresses +export const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const; +export const PORTAL_ADDRESS = '0x399Dbd5DF04f83103F77A58cBa2B7c4d3cdede97' as const; + +/** + * Fund the test account with USDC by directly writing to the ERC-20 storage slot. + * Circle's USDC uses mapping slot 9 for balances. + * Storage key = keccak256(abi.encode(account, 9)) + */ +export async function fundTestAccountWithUsdc(amountUsdc: number): Promise { + const USDC_BALANCE_SLOT = 9n; + const storageKey = keccak256( + encodeAbiParameters(parseAbiParameters('address, uint256'), [TEST_ADDRESS, USDC_BALANCE_SLOT]) + ); + const encodedBalance = encodeAbiParameters(parseAbiParameters('uint256'), [ + parseUnits(String(amountUsdc), 6), + ]); + + await fetch(ANVIL_RPC, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'anvil_setStorageAt', + params: [USDC_ADDRESS, storageKey, encodedBalance], + }), + }); +} + +/** Read on-chain USDC balance of an address (for assertion in tests). */ +export async function getUsdcBalance(address: string): Promise { + const client = createPublicClient({ chain: base, transport: http(ANVIL_RPC) }); + return client.readContract({ + address: USDC_ADDRESS, + abi: [ + { + name: 'balanceOf', + type: 'function', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + }, + ], + functionName: 'balanceOf', + args: [address as `0x${string}`], + }) as Promise; +} diff --git a/tests/e2e/setup/global-setup.ts b/tests/e2e/setup/global-setup.ts new file mode 100644 index 0000000..ef857c7 --- /dev/null +++ b/tests/e2e/setup/global-setup.ts @@ -0,0 +1,39 @@ +import { execFileSync } from 'child_process'; +import path from 'path'; + +import * as dotenv from 'dotenv'; +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); + +const COMPOSE_FILE = path.resolve(__dirname, '../docker-compose.e2e.yml'); +const ANVIL_URL = 'http://localhost:8545'; +const MAX_WAIT_MS = 60_000; + +export default async function globalSetup(): Promise { + if (!process.env.FORK_RPC_URL) { + throw new Error( + 'E2E tests require FORK_RPC_URL (Base mainnet archive RPC).\n' + + 'Set it in your .env or run: FORK_RPC_URL=https://... pnpm test:e2e' + ); + } + + execFileSync('docker', ['compose', '-f', COMPOSE_FILE, 'up', '-d'], { stdio: 'inherit' }); + + const start = Date.now(); + while (Date.now() - start < MAX_WAIT_MS) { + try { + const res = await fetch(ANVIL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }), + }); + if (res.ok) { + process.stderr.write('[E2E] Anvil fork of Base mainnet is ready\n'); + return; + } + } catch { + /* not ready yet */ + } + await new Promise(r => setTimeout(r, 1_000)); + } + throw new Error(`Anvil did not become ready within ${MAX_WAIT_MS / 1000}s`); +} diff --git a/tests/e2e/setup/global-teardown.ts b/tests/e2e/setup/global-teardown.ts new file mode 100644 index 0000000..2a74d77 --- /dev/null +++ b/tests/e2e/setup/global-teardown.ts @@ -0,0 +1,10 @@ +import { execFileSync } from 'child_process'; +import path from 'path'; + +const COMPOSE_FILE = path.resolve(__dirname, '../docker-compose.e2e.yml'); + +export default function globalTeardown(): void { + execFileSync('docker', ['compose', '-f', COMPOSE_FILE, 'down', '--volumes'], { + stdio: 'inherit', + }); +} diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/intent-publishing.test.ts b/tests/integration/intent-publishing.test.ts new file mode 100644 index 0000000..b0e9400 --- /dev/null +++ b/tests/integration/intent-publishing.test.ts @@ -0,0 +1,173 @@ +/** + * Integration tests — Intent publishing flow + * + * Verifies the end-to-end pipeline across modules: + * PublisherFactory (chain type dispatch) → + * EvmPublisher (token approval + portal contract call) + * + * All external I/O (RPC calls) is mocked. + */ + +import { EvmPublisher } from '@/blockchain/evm/evm.publisher'; +import { AddressNormalizer } from '@/blockchain/utils/address-normalizer'; +import { ChainConfig } from '@/config/chains'; +import { ErrorCode, RoutesCliError } from '@/shared/errors'; +import { KeyHandle } from '@/shared/security'; +import { BlockchainAddress, ChainType } from '@/shared/types'; + +import { + createMockEvmClientFactory, + mockEvmPublicClient, + mockEvmWalletClient, +} from '../__mocks__/evm-client-factory.mock'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const PORTAL_ADDR_EVM = '0x399Dbd5DF04f83103F77A58cBa2B7c4d3cdede97'; +const PROVER_ADDR_EVM = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; +const CREATOR_ADDR_EVM = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; // vitalik.eth +const TOKEN_ADDR_EVM = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base + +/** Hardhat/Anvil account #0 — deterministic test private key. */ +const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +const portalUniversal = AddressNormalizer.normalize(PORTAL_ADDR_EVM, ChainType.EVM); +const proverUniversal = AddressNormalizer.normalize(PROVER_ADDR_EVM, ChainType.EVM); +const creatorUniversal = AddressNormalizer.normalize(CREATOR_ADDR_EVM, ChainType.EVM); +const tokenUniversal = AddressNormalizer.normalize(TOKEN_ADDR_EVM, ChainType.EVM); + +// Minimal mocks for EvmPublisher's NestJS DI dependencies +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockRegistry: any = { isRegistered: () => true }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockChains: any = { + findChainById: (id: bigint) => + id === 10n + ? { + id: 10n, + name: 'Optimism', + type: ChainType.EVM, + env: 'production', + rpcUrl: 'https://mainnet.optimism.io', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } + : undefined, +}; + +/** Source chain with pre-configured portal + prover. */ +const SOURCE_CHAIN: ChainConfig = { + id: 1n, + name: 'Ethereum', + type: ChainType.EVM, + env: 'production', + rpcUrl: 'https://cloudflare-eth.com', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + portalAddress: portalUniversal, + proverAddress: proverUniversal, +}; + +const DEST_CHAIN: ChainConfig = { + id: 10n, + name: 'Optimism', + type: ChainType.EVM, + env: 'production', + rpcUrl: 'https://mainnet.optimism.io', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, +}; + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('Intent publishing flow (integration)', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Restore mock client defaults for every test + (mockEvmPublicClient.getBalance as jest.Mock).mockResolvedValue(1_000_000_000_000_000_000n); + (mockEvmPublicClient.readContract as jest.Mock).mockResolvedValue(10_000_000n); + (mockEvmPublicClient.waitForTransactionReceipt as jest.Mock).mockResolvedValue({ + status: 'success', + logs: [], + }); + (mockEvmWalletClient.writeContract as jest.Mock).mockResolvedValue('0xmockapprovetxhash'); + (mockEvmWalletClient.sendTransaction as jest.Mock).mockResolvedValue('0xmockpublishtxhash'); + }); + + // ── 1. Invalid recipient address ─────────────────────────────────────────── + + describe('Invalid recipient address', () => { + it('throws RoutesCliError with INVALID_ADDRESS when EVM address is malformed', () => { + expect(() => + AddressNormalizer.normalize('not-a-valid-address' as BlockchainAddress, ChainType.EVM) + ).toThrow(expect.objectContaining({ code: ErrorCode.INVALID_ADDRESS })); + }); + + it('thrown error is a RoutesCliError instance', () => { + expect(() => + AddressNormalizer.normalize('0xBAD' as BlockchainAddress, ChainType.EVM) + ).toThrow(RoutesCliError); + }); + + it('isUserError is true so the CLI can render a friendly message', () => { + let caught: RoutesCliError | null = null; + try { + AddressNormalizer.normalize('garbage' as BlockchainAddress, ChainType.EVM); + } catch (err: unknown) { + if (err instanceof RoutesCliError) caught = err; + } + expect(caught).not.toBeNull(); + expect(caught!.isUserError).toBe(true); + }); + }); + + // ── 2. Insufficient balance ──────────────────────────────────────────────── + + describe('Insufficient balance', () => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const largeReward = () => ({ + deadline: 9_999_999_999n, + creator: creatorUniversal, + prover: proverUniversal, + nativeAmount: 0n, + tokens: [{ token: tokenUniversal, amount: 1_000_000_000n }], // 1000 USDC required + }); + + it('validate() returns { valid: false } when token balance is below required', async () => { + const publisher = new EvmPublisher( + SOURCE_CHAIN.rpcUrl, + mockRegistry, + mockChains, + createMockEvmClientFactory() + ); + (mockEvmPublicClient.readContract as jest.Mock).mockResolvedValue(100n); // 0.0001 USDC + + const result = await publisher.validate(largeReward(), CREATOR_ADDR_EVM); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatch(/Insufficient token balance/i); + }); + + it('publish() returns { success: false } when token balance check fails', async () => { + const publisher = new EvmPublisher( + SOURCE_CHAIN.rpcUrl, + mockRegistry, + mockChains, + createMockEvmClientFactory() + ); + // balanceOf returns insufficient amount (100 < 1_000_000_000) + (mockEvmPublicClient.readContract as jest.Mock).mockResolvedValue(100n); + + const result = await publisher.publish( + SOURCE_CHAIN.id, + DEST_CHAIN.id, + largeReward(), + '0x', + new KeyHandle(TEST_PRIVATE_KEY), + portalUniversal + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Insufficient token balance/i); + }); + }); +}); diff --git a/tests/setup/register-chain-handlers.ts b/tests/setup/register-chain-handlers.ts new file mode 100644 index 0000000..9efd593 --- /dev/null +++ b/tests/setup/register-chain-handlers.ts @@ -0,0 +1,6 @@ +// Registers all built-in chain handlers into the chainRegistry singleton before each test suite. +// Required because tests import AddressNormalizer directly, bypassing index.ts which normally +// performs this registration as side-effect imports. +import '@/blockchain/evm/evm-chain-handler'; +import '@/blockchain/tvm/tvm-chain-handler'; +import '@/blockchain/svm/svm-chain-handler'; diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 3adc0ab..0c4e7c3 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -6,10 +6,12 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, + "allowJs": true, "baseUrl": "..", "paths": { "@/*": ["src/*"] } }, - "include": ["../src/**/*", "./**/*"] + "include": ["../src/**/*", "./**/*"], + "exclude": ["node_modules"] } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 51cd734..6314aa6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,22 +12,23 @@ "outDir": "./dist", "baseUrl": "./", "incremental": true, - "skipLibCheck": true, - "skipDefaultLibCheck": true, + "skipLibCheck": false, + "skipDefaultLibCheck": false, "resolveJsonModule": true, "strict": true, "strictNullChecks": true, "noImplicitAny": true, "strictBindCallApply": true, "strictFunctionTypes": true, - "strictPropertyInitialization": false, + "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, "paths": { "@/*": [ "src/*" @@ -36,6 +37,8 @@ }, "exclude": [ "node_modules", - "dist" + "dist", + "src/scripts", + "tests" ] } diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..8cb360b --- /dev/null +++ b/typedoc.json @@ -0,0 +1,6 @@ +{ + "entryPoints": ["src/index.ts"], + "out": "docs/api", + "excludePrivate": true, + "excludeInternal": true +}