diff --git a/.eslintrc.js b/.eslintrc.js index f7adee3109..daa368f94b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,7 +21,14 @@ module.exports = { rules: { 'no-console': 'off', '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + // `ignoreRestSiblings` allows the idiomatic way to drop a key — + // `const { poolType, ...rest } = obj` — which is not dead code; the named sibling is + // how the rest is defined. `varsIgnorePattern` keeps a deliberately-unread binding + // legible as deliberate. + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', ignoreRestSiblings: true }, + ], semi: [2, 'always'], 'prettier/prettier': 'error', '@typescript-eslint/no-var-requires': 'off', diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 1ffec34602..b645ff8cec 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -80,6 +80,21 @@ jobs: # Copy template configurations cp -rf src/templates/* conf/ + # The committed spec is what consumers generate their clients from, and the tests + # that check it read the committed copy — so a route change with no regeneration + # leaves them passing against a stale document. Only regenerating and comparing + # catches that. The generator writes the template port and placeholder wallets, so + # this produces the same bytes here as on any developer's machine. + - name: Check openapi.json is regenerated + shell: bash + run: | + pnpm generate:openapi + if ! git diff --quiet -- openapi.json; then + echo "::error::openapi.json does not match the routes. Run 'pnpm generate:openapi' and commit the result." + git --no-pager diff -- openapi.json + exit 1 + fi + - name: Run unit test coverage if: github.event_name == 'pull_request' shell: bash diff --git a/.prettierignore b/.prettierignore index ef25ecea08..bb55cce3c5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,5 @@ certs *.yml coverage dist -vendor \ No newline at end of file +vendor +openapi.json diff --git a/CLAUDE.md b/CLAUDE.md index 8f2a74ea2f..8882452434 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,19 +251,6 @@ Gateway implements RPC provider abstraction for optimized blockchain connectivit - Regional optimization (slc, ewr, lon, fra, ams, sg, tyo) - Connection warming for reduced latency -### Testing RPC Providers -Live integration tests in `scripts/`: -- `test-infura-live.js`: Test Infura integration with real API key -- `test-helius-live.js`: Test Helius integration with real API key -- `test-provider-switching.js`: Test provider switching functionality - -Run tests: -```bash -# Requires configured API keys in conf/rpc/*.yml -node scripts/test-infura-live.js -node scripts/test-helius-live.js -``` - ### Adding New RPC Provider 1. Create config template: `src/templates/rpc/{provider}.yml` 2. Create JSON schema: `src/templates/namespace/{provider}-schema.json` @@ -271,8 +258,7 @@ node scripts/test-helius-live.js 4. Implement service class: `src/chains/{chain}/{provider}-service.ts` 5. Update chain connector to support provider selection 6. Add `rpcProvider` enum to network schema -7. Create live integration test script -8. Document configuration and usage +7. Document configuration and usage ## Hummingbot Gateway Endpoint Standardization - This repo standardized DEX and chain endpoints that are used by Hummingbot strategies. See this branch for the matching code, especially the Gateway connector classes https://github.com/hummingbot/hummingbot/tree/development diff --git a/README.md b/README.md index 54fc4cef19..8b9bf990b8 100644 --- a/README.md +++ b/README.md @@ -112,29 +112,37 @@ Gateway uses [Swagger](https://swagger.io/) for API documentation. When running - `POST /chains/{chain}/approve` - Approve token spending - `POST /chains/{chain}/wrap` - Wrap/unwrap native tokens -#### Connector Routes (`/connectors/{dex}/{type}/*`) - -**Router Operations** (e.g., `/connectors/jupiter/router/*`): -- `POST /quote` - Get swap quote from aggregator -- `POST /swap` - Execute swap through aggregator - -**AMM Operations** (e.g., `/connectors/raydium/amm/*`): -- `POST /poolInfo` - Get pool details -- `POST /positionInfo` - Get liquidity position info -- `POST /quoteSwap` - Get swap quote -- `POST /executeSwap` - Execute swap -- `POST /quoteLiquidity` - Quote add/remove liquidity -- `POST /addLiquidity` - Add liquidity to pool -- `POST /removeLiquidity` - Remove liquidity from pool - -**CLMM Operations** (e.g., `/connectors/uniswap/clmm/*`): -- `POST /poolInfo` - Get concentrated liquidity pool info -- `POST /openPosition` - Open new position -- `POST /closePosition` - Close existing position -- `POST /addLiquidity` - Add liquidity to position -- `POST /removeLiquidity` - Remove liquidity from position -- `POST /collectFees` - Collect earned fees -- `POST /positionsOwned` - List owned positions +#### Trading Routes (`/trading/{type}/*`) + +The trading type is a path segment and the connector is a `connector` parameter, so +one set of routes covers every DEX. + +**Router** (`/trading/router/*`) — aggregators that route across pools (Jupiter, 0x, dflow, okx, titan, Uniswap, PancakeSwap): +- `GET /trading/router/quote-swap` - Quote a swap (returns a `quoteId`) +- `POST /trading/router/execute-quote` - Execute a previously fetched quote by id +- `POST /trading/router/execute-swap` - Quote and execute in one call + +**AMM** (`/trading/amm/*`) — constant-product pools (Uniswap V2, PancakeSwap V2, Raydium CPMM, Meteora DAMM v2): +- `GET /trading/amm/quote-swap` / `POST /trading/amm/execute-swap` - Swap against a single pool +- `GET /trading/amm/pool-info` - Pool reserves, price, fee +- `GET /trading/amm/position-info` - Wallet's liquidity in a pool +- `GET /trading/amm/positions-owned` - All of a wallet's positions +- `GET /trading/amm/quote-liquidity` - Two-sided deposit quote +- `POST /trading/amm/open` / `close` - Open or close a position (fungible-LP AMMs deposit/withdraw in full) +- `POST /trading/amm/add` / `remove` - Change a position's liquidity +- `POST /trading/amm/create-pool` + +**CLMM** (`/trading/clmm/*`) — concentrated liquidity (Uniswap V3, PancakeSwap V3, Raydium, Meteora DLMM, Orca): +- `GET /trading/clmm/quote-swap` / `POST /trading/clmm/execute-swap` - Swap against a single pool +- `GET /trading/clmm/pool-info` - Pool info (optionally with bin/tick liquidity) +- `GET /trading/clmm/position-info` - Position details +- `GET /trading/clmm/positions-owned` - All of a wallet's positions +- `GET /trading/clmm/quote-liquidity` - Deposit split for a candidate range +- `GET /trading/clmm/fetch-pools` - Pool discovery from the DEX's own listing API +- `POST /trading/clmm/open` / `close` - Open or close a position +- `POST /trading/clmm/add` / `remove` - Change a position's liquidity +- `POST /trading/clmm/collect-fees` - Collect earned fees +- `POST /trading/clmm/create-pool` #### Wallet Routes (`/wallet/*`) - `GET /wallet` - List all wallets @@ -405,43 +413,25 @@ docker compose up -d --build - `DELETE /wallet/remove` - Remove wallet - `POST /wallet/sign` - Sign message -### Chain Operations +### Chain Operations (`/chains/{chain}/*`) -#### Ethereum/EVM (`/chains/ethereum`) -- `GET /status` - Chain connection status -- `GET /tokens` - Get token information -- `GET /balances` - Get wallet balances -- `GET /allowances` - Check token allowances -- `POST /approve` - Approve token spending -- `GET /poll` - Poll transaction status +One parameterized set of routes serves every chain, so a new chain needs no new paths: +- `GET /chains/{chain}/status` - Chain connection status and block height +- `GET /chains/{chain}/estimate-gas` - Current transaction fee estimate +- `POST /chains/{chain}/balances` - Wallet token balances +- `POST /chains/{chain}/poll` - Poll a transaction by signature/hash +- `POST /chains/{chain}/wrap` / `unwrap` - Wrap or unwrap the native token -#### Solana (`/chains/solana`) -- `GET /status` - Chain connection status -- `GET /tokens` - Get token information -- `GET /balances` - Get wallet balances -- `GET /poll` - Poll transaction status +EVM-only operations keep chain-specific paths, since they have no meaning elsewhere: +- `POST /chains/ethereum/allowances` - Check token allowances +- `POST /chains/ethereum/approve` - Approve token spending ### DEX Trading Endpoints -#### Router Operations (DEX Aggregators) -- `GET /connectors/{dex}/router/quote-swap` - Get swap quote -- `POST /connectors/{dex}/router/execute-swap` - Execute swap without quote -- `POST /connectors/{dex}/router/execute-quote` - Execute pre-fetched quote -- `GET /connectors/0x/router/get-price` - Get price estimate (0x only) - -#### AMM Operations (Uniswap V2, PancakeSwap V2, Raydium) -- `GET /connectors/{dex}/amm/pool-info` - Pool information -- `GET /connectors/{dex}/amm/position-info` - LP position details -- `POST /connectors/{dex}/amm/add-liquidity` - Add liquidity -- `POST /connectors/{dex}/amm/remove-liquidity` - Remove liquidity - -#### CLMM Operations (Uniswap V3, PancakeSwap V3, Raydium, Meteora) -- `GET /connectors/{dex}/clmm/pool-info` - Pool information -- `GET /connectors/{dex}/clmm/positions-owned` - List positions -- `POST /connectors/{dex}/clmm/open-position` - Open position -- `POST /connectors/{dex}/clmm/add-liquidity` - Add to position -- `POST /connectors/{dex}/clmm/remove-liquidity` - Remove from position -- `POST /connectors/{dex}/clmm/collect-fees` - Collect fees +See [Trading Routes](#trading-routes-tradingtype) above. Every trading route takes +`chainNetwork` (e.g. `solana-mainnet-beta`) and `connector` (e.g. `jupiter`), and the +full request/response schemas are in the OpenAPI document at `/docs` (or `openapi.json`, +regenerated with `pnpm generate:openapi`). ## Contribution @@ -626,64 +616,22 @@ The test directory is organized as follows: ``` /test - /chains/ # Chain endpoint tests - chain.test.js # Chain routes test - ethereum.test.js # Ethereum chain tests - solana.test.js # Solana chain tests - /connectors/ # Connector endpoint tests by protocol - /jupiter/ # Jupiter connector tests - /uniswap/ # Uniswap connector tests - /raydium/ # Raydium connector tests - /meteora/ # Meteora connector tests - /pancakeswap/ # PancakeSwap (EVM) connector tests - /pancakeswap-sol/ # PancakeSwap Solana connector tests - /mocks/ # Mock response data - /chains/ # Chain mock responses - chains.json # Chain routes mock response - /ethereum/ # Ethereum mock responses - /solana/ # Solana mock responses - /connectors/ # Connector mock responses - /services/ # Service tests - /data/ # Test data files - /wallet/ # Wallet tests - /config/ # Configuration tests - /jest-setup.js # Test environment configuration - -/scripts # Live testing and utility scripts - test-helius-live.js # Helius RPC provider integration tests - test-infura-live.js # Infura RPC provider integration tests - test-provider-switching.js # RPC provider switching tests + /chains/ # Chain route tests (ethereum, solana) + /connectors/ # Connector tests, one directory per connector + /trading/ # The unified /trading/* route tests + /mocks/ # Shared mock modules (TypeScript) + /helpers/ /utils/ # Test helpers and fastifyWithTypeProvider + /services/ /wallet/ /config/ /pools/ /tokens/ /rpc/ + jest-setup.js # Test environment configuration + +/scripts # Utility scripts + generate-openapi.ts # Write openapi.json from the live route table + create-wallet.ts # Wallet creation helper + add-bsc-tokens.ts # Token list maintenance + add-pancakeswap-pools.ts # Pool list maintenance + migrate-pool-templates.ts # Pool template migration ``` -#### RPC Provider Testing - -Gateway includes comprehensive testing for RPC provider integrations: - -**Live Integration Tests** (`scripts/test-*-live.js`): -- Test real API connectivity with configured keys -- Verify WebSocket connections and features -- Measure performance improvements vs standard RPC -- Validate network-specific endpoint mappings - -**Running RPC Provider Tests**: -```bash -# Test Infura integration (requires API key in conf/rpc/infura.yml) -node scripts/test-infura-live.js - -# Test Helius integration (requires API key in conf/rpc/helius.yml) -node scripts/test-helius-live.js - -# Test provider switching functionality -node scripts/test-provider-switching.js -``` - -**Test Coverage Areas**: -- Provider initialization and configuration loading -- Automatic fallback to standard RPC on failures -- Network-specific endpoint resolution -- WebSocket connection establishment -- Performance benchmarking and health checks - For more details on the test setup and structure, see [Test README](./test/README.md). ## Development Guide @@ -1010,12 +958,18 @@ testProviderIntegration(); 3. **Implement trading methods** based on supported operations -4. **Create route files** following the pattern: - - Router routes in `router-routes/` (for DEX aggregators) - - AMM routes in `amm-routes/` (for V2-style pools) - - CLMM routes in `clmm-routes/` (for concentrated liquidity) +4. **Export plain operation functions** — not routes — following the pattern: + - Router operations in `router-routes/` (for DEX aggregators): `quoteSwap`, `executeSwap`, `executeQuote` + - AMM operations in `amm-routes/` (for V2-style pools) + - CLMM operations in `clmm-routes/` (for concentrated liquidity) + + Each takes plain arguments and returns the shared response shape. Connectors no + longer register HTTP routes of their own; the unified `/trading/*` routes are the + only surface. -5. **Add configuration and register** in `src/connectors/connector.routes.ts` +5. **Add one entry per trading type** to `src/trading/connector-registry.ts`, which is + what wires the functions into the unified routes and into the `connector` enums the + schemas (and the OpenAPI document) advertise. ### Testing Requirements diff --git a/docs/connectors/meteora-damm-v2.md b/docs/connectors/meteora-damm-v2.md index fe9f3801d1..9fa16bc025 100644 --- a/docs/connectors/meteora-damm-v2.md +++ b/docs/connectors/meteora-damm-v2.md @@ -4,20 +4,22 @@ DAMM v2 is Meteora's constant-product AMM, implemented by the on-chain **cp-amm* (`cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG`) and driven by the [`@meteora-ag/cp-amm-sdk`](https://docs.meteora.ag/developer-guides/damm-v2/typescript-sdk/getting-started). -Gateway exposes it under the standard AMM interface at `/connectors/meteora/amm/*`, mirroring -the Raydium AMM connector: +Gateway exposes it through the unified AMM interface at `/trading/amm/*` with +`connector=meteora`, the same routes that serve every other AMM connector: | Endpoint | Method | Notes | |---|---|---| -| `/connectors/meteora/amm/pool-info` | GET | Pool reserves, price, base (cliff) fee % | -| `/connectors/meteora/amm/position-info` | GET | Wallet's aggregate liquidity in a pool + per-position `positions[]` breakdown | -| `/connectors/meteora/amm/positions-owned` | GET | All of the wallet's DAMM v2 positions across pools | -| `/connectors/meteora/amm/quote-swap` | GET | Exact-in (SELL) / exact-out (BUY) quote | -| `/connectors/meteora/amm/execute-swap` | POST | Swap | -| `/connectors/meteora/amm/quote-liquidity` | GET | Two-sided deposit quote | -| `/connectors/meteora/amm/add-liquidity` | POST | Add to a specific position (`positionAddress`) or open a new one | -| `/connectors/meteora/amm/remove-liquidity` | POST | Remove a % from a specific position (`positionAddress` **required**) | -| `/connectors/meteora/amm/create-pool` | POST | Create + seed a new pool | +| `/trading/amm/pool-info` | GET | Pool reserves, price, base (cliff) fee % | +| `/trading/amm/position-info` | GET | Wallet's aggregate liquidity in a pool + per-position `positions[]` breakdown | +| `/trading/amm/positions-owned` | GET | All of the wallet's DAMM v2 positions across pools | +| `/trading/amm/quote-swap` | GET | Exact-in (SELL) / exact-out (BUY) quote | +| `/trading/amm/execute-swap` | POST | Swap | +| `/trading/amm/quote-liquidity` | GET | Two-sided deposit quote | +| `/trading/amm/open` | POST | Open a new position (NFT) and seed it with liquidity | +| `/trading/amm/add` | POST | Add to a specific position (`positionAddress`) or open a new one | +| `/trading/amm/remove` | POST | Remove a % from a specific position (`positionAddress` **required**) | +| `/trading/amm/close` | POST | Withdraw everything and close the position NFT, refunding its rent (`positionAddress` **required**) | +| `/trading/amm/create-pool` | POST | Create + seed a new pool | The implementation deliberately keeps to "the basics" so it fits the shared AMM schema. This document records where DAMM v2 differs from a classic fungible-LP AMM (e.g. Raydium AMM/CPMM), @@ -91,7 +93,7 @@ resolves the seed price in this priority order: 1. **`initialPrice`** (quote per base) if provided — `quoteTokenAmount = baseTokenAmount × initialPrice`. 2. **`quoteTokenAmount`** if provided — the `baseTokenAmount : quoteTokenAmount` ratio sets the price. 3. **Otherwise, the current market price is fetched** from the unified swap router - (`/trading/swap/quote`, i.e. the network's configured `swapProvider` — Jupiter on Solana, which + (`/trading/router/quote-swap`, i.e. the network's configured `swapProvider` — Jupiter on Solana, which aggregates existing venues) via a SELL quote of a small probe (1% of `baseTokenAmount`), and the pool is seeded there. The probe is kept small so the quote approximates the marginal market price; quoting the full seed amount would bake its own price impact into the seed price and diff --git a/docs/retry-architecture.md b/docs/retry-architecture.md new file mode 100644 index 0000000000..e2b814af05 --- /dev/null +++ b/docs/retry-architecture.md @@ -0,0 +1,240 @@ +# LP Close Retry Architecture + +**Origin:** [gateway#678](https://github.com/hummingbot/gateway/issues/678) — an Orca LP close failed with Whirlpool error `6018` (`TokenMinSubceeded`, misreported as `MATH_OVERFLOW`), the LP executor went terminally `FAILED` with the position still open on-chain, and subsequent stop requests returned 404. Fixing it properly required deciding **where retries live** across Gateway, the Hummingbot connector, the LP executor, the controller, and hummingbot-api — and auditing every layer against that model. + +Auditing those layers turned up defects on the paths the LP flow depends on but that #678 never named: the transaction-status contract the poller reads, and the CLMM pool-info contract the UI and agents read. Both are documented here because they share the same root cause as #678 — a caller could not tell a definitive answer from a transient one, or could not ask for what it needed at all. + +This document describes the issues found and the architecture that now stands, spanning five repositories: [gateway#679](https://github.com/hummingbot/gateway/pull/679), [hummingbot#8424](https://github.com/hummingbot/hummingbot/pull/8424), [hummingbot-api#217](https://github.com/hummingbot/hummingbot-api/pull/217), [hummingbot-api-client#25](https://github.com/hummingbot/hummingbot-api-client/pull/25), [condor#204](https://github.com/hummingbot/condor/pull/204). + +--- + +## 1. The principle + +> **Gateway is a stateless transaction oracle.** One HTTP request = one attempt at one on-chain action, built from fresh on-chain state, answered with either a confirmed result or a **typed error**. Hummingbot holds the position and order state, so Hummingbot owns retries — at distinct altitudes, each answering a different question. + +| Layer | Question it answers | What it owns | +|---|---|---| +| **Gateway** | "Did *this one attempt* land?" | Reads and transport only: RPC 429s, confirmation polling until blockhash expiry, typed error classification, pre-broadcast simulation rejection. Never re-submits a write. | +| **Connector** (`gateway_base.py`, `gateway.py`) | "Should I ask Gateway for *another attempt*?" | `_execute_with_retry`: transport timeouts only, the same for every operation. What an operation error means is the caller's decision. | +| **LP executor** (`lp_executor.py`) | "What does this failure mean for *the position lifecycle*?" | **The close retry loop** — state-machine re-entry with a bounded budget and exponential backoff, a fresh position read before every re-submit, and the terminal close type. It passes `max_retries=0` so the connector makes exactly one request per re-entry. | +| **Controller** (`lp_rebalancer.py`) | "What does this failure mean for *the strategy*?" | Re-creates a fresh executor with freshly computed bounds; halts instead of stacking exposure over an unresolved position. | +| **hummingbot-api / condor** | "What survives the process, and who is told?" | Durable orphan records, listing and resolution endpoints, agent-facing warnings. | + +```mermaid +flowchart TB + subgraph HB["Hummingbot — stateful, owns retries"] + C["Controller: lp_rebalancer
strategy: re-create after clean FAILED,
halt over unresolved position
"] + E["LP executor: lp_executor
lifecycle: CLOSING re-entry — the ONE close retry loop,
max_retries + 1 attempts, exponential backoff (2ⁿ s, cap 30 s),
fresh position read before every re-submit
"] + K["Connector: gateway_base / gateway
operation: _execute_with_retry — transport timeouts only;
close passes max_retries=0: one request per re-entry
"] + end + subgraph GW["Gateway — stateless, one attempt per request"] + R["Route: build tx from FRESH on-chain state
quote → simulate → sign → broadcast"] + S["Chain layer: typed errors, 429 interceptor,
confirm-poll to blockhash expiry,
pre-broadcast simulation guard"] + end + A["hummingbot-api / condor
durable orphan records, stop semantics, agent visibility"] + C --> E + E --> K + K -->|"each retry = fresh rebuild"| R + R --> S + S -->|"CONFIRMED · typed error [code: X]"| K + K --> E + E -->|terminal close_type| C + E -.->|persisted final state| A +``` + +The property this preserves: **a connector-level retry gets fresh state for free**, because every re-POST re-runs the route's fetch → quote → build → simulate pipeline. A retry loop inside a Gateway route would duplicate that one layer down, per connector, invisible to the strategy, and stacking under the connector's own loop. + +--- + +## 2. Issues found + +The #678 investigation and the adversarial review of the fix surfaced the following defects. Each is fixed in this PR family unless marked as an accepted residual (§4). + +### Gateway + +1. **Misattributed on-chain errors.** Orca `6018` was missing from the error table (reported as `MATH_OVERFLOW`), and the parser attributed custom errors to the *first* program in the log — simulation logs open with a ComputeBudget prelude, so the failing DEX program's table was never consulted. Errors are now attributed to the program on the `failed: custom program error` line, and `6018` maps to `SLIPPAGE_EXCEEDED`. +2. **Doomed transactions were broadcast anyway.** The compute-estimation simulation's `err` was ignored (only `unitsConsumed` was read), so a transaction guaranteed to fail was signed, broadcast, and paid fees. Both send paths now reject a failed simulation with a typed 400 **before broadcast** — a failed attempt costs nothing. +3. **Transient read errors served as definitive closure.** `Orca.getPositionInfo` swallowed every error into `null`, which the route surfaced as the position-specific 404 — so an RPC blip inside Gateway read as "position closed". It now returns `null` only for a definitive account-does-not-exist result and rethrows everything else. +4. **A dropped transaction was indistinguishable from a pending one.** `getTransaction` (commitment `confirmed`) returns null both for a transaction awaiting confirmation and for one the cluster has never seen, and `/poll` reported both as `txStatus 0` (pending) — forever. The poll now consults the signature-status cache (with history search) and reports an unknown signature as **`NOT_FOUND` (-2)**, which is terminal once the transaction's blockhash has expired (~90 s). +5. **The two poll routes spoke different dialects.** Ethereum's poll used raw numbers including `2` ("likely to be processed") and `3` ("likely stuck") that no consumer understood, reported not-found as `-1` (failed) after blocking the request for three in-route 1-second retries, and — via `typeof receipt.status === 'number' ? 1 : -1` — reported **reverted** transactions (receipt status `0`, which is a number) as CONFIRMED, so a reverted swap polled as filled. Both routes now share one `TransactionStatusCode` contract: `NOT_FOUND (-2) / FAILED (-1) / PENDING (0) / CONFIRMED (1)`. +6. **Post-SDK-migration slippage exposure.** The migrated Orca close route quotes withdrawal minimums at the configured `slippagePct` (~1%) rather than the legacy 50% buffer — exactly the condition under which the #678 race is reachable. The guards above are load-bearing, not defense-in-depth. + +### Hummingbot connector + +7. **The connector silently decided what operation errors meant.** `SLIPPAGE_EXCEEDED`/`SIMULATION_FAILED` were classified inside `_execute_with_retry`, one size for all operations — and any attempt to make that operation-aware (an opt-in error set, a per-operation inner budget) produced two nested retry loops with multiplying budgets. The connector now retries **transport timeouts only**, identically for every operation; what an operation error means is the caller's decision, and for close the caller is the executor's single loop (issue 11). +8. **The landed-but-failed shape had no error code.** A broadcast transaction that failed on-chain raised with no `[code:]` marker at all, so callers could not classify the flagship #678 failure shape. It now raises typed `TX_NOT_CONFIRMED`. +9. **`get_position_info` swallowed every exception into `None`**, and the executor read `None` as "already closed" → `COMPLETE` — a transient RPC error during close could **abandon a live position while reporting success**. The contract is now: `None` only for a position-specific 404; everything else re-raises. Three coupled requirements: (a) the match is position-specific, because the HTTP client stamps "(Not Found)" on *every* 404 — a missing route after a redeploy read as "position gone"; (b) existence decisions use an **uncached** read (`get_position_info_fresh`) — the 5 s TTL cache stores `None` like any value, so one cached 404 masqueraded as several independent confirmations; (c) external-close detection requires the position to have been **seen on-chain at least once** plus 3 consecutive fresh misses. +10. **Pending-transaction polling was unbounded.** `update_order_status` polled any in-flight order at 1 s forever; combined with issue 4, a dropped transaction never resolved — the order never failed and burned an RPC call per second until restart. The connector now treats `NOT_FOUND` as transient while the order is younger than `TX_NOT_FOUND_DEADLINE` (120 s, past blockhash validity) and afterwards feeds each miss to the order tracker's existing lost-order machinery, which fails the order after repeated consecutive misses. `PENDING` remains unbounded by design: the chain has seen the transaction, so it can still confirm. + +### LP executor + +11. **Close failure was terminal on the first error.** `_handle_close_failure` jumped straight to `FAILED` with the position still open. It now counts the attempt, arms exponential backoff (2ⁿ s, capped 30 s — so a Gateway restart spans a few retries instead of burning the whole budget in seconds), and stays `CLOSING`; the family hook `evaluate_max_retries` decides termination after `max_retries + 1` attempts. This re-entry is the **only** close retry loop: the executor passes `max_retries=0` so the connector makes one request per attempt, and budgets cannot multiply. +12. **The wrong terminal close type.** `FAILED` in the executor family means "abnormal end with *no residual exposure*" — everything downstream (hold store, PnL, dashboards) assumes it. Terminating an exhausted close as `FAILED` forced a parallel side-channel at every layer. An exhausted close with the position still on-chain now terminates as an **involuntary `POSITION_HOLD`** with `hold_reason: "close_retries_exhausted"` and a zero-amount marker order carrying the position address — riding the existing hold machinery (DB-recovered on the API path) instead of a bespoke flag. `FAILED` is reserved for exhaustion with nothing left on-chain (e.g. an open rejected at simulation). + +### Controller, hummingbot-api, condor + +13. **The controller stacked exposure over a live position.** After a terminal executor still holding a position, `lp_rebalancer` created a fresh executor — a fresh `lp_executor` cannot adopt an existing position (it always mints a new one), so this doubled the funded exposure. It now halts and logs until the orphan is resolved. +14. **Stopping a terminal executor returned 404.** The API's completion handler pops the executor from memory within one tick, so every stop against a terminal executor hit the "unknown id" branch — the #678 dead-end. Stop is now DB-aware: any DB-known, not-in-memory executor returns `already_terminated` with its final `close_type`, `position_address`, and `hold_reason`; 404 is reserved for ids the database has never seen. +15. **Failures were invisible to agents.** The condor tick prompt listed only `RUNNING` executors, so a terminal executor with a live position *vanished from view*. The provider now surfaces orphaned executors with a warning, and `manage_executors` gained `orphaned` and `resolve_orphan` actions. +16. **Orphans had no durable record or resolution path.** The persisted final state now carries the orphan shape; `GET /executors/positions/orphaned` lists candidates (involuntary holds, legacy `FAILED`-with-position, and `SYSTEM_CLEANUP` LP executors from an API restart — the latter flagged `needs_onchain_reconciliation` since no final state was persisted); `POST /executors/{id}/resolve-orphan` marks a recovered position so it stops surfacing. + +### CLMM pool-info + +The same audit covered the read path that agents and the dashboard use to see a pool before opening a position. + +17. **The bin distribution was unreachable through the unified route.** `GET /trading/clmm/pool-info` — the route hummingbot-api and condor read through — had no `binCount` in its querystring schema and called every connector as `(fastify, network, poolAddress)`, dropping the parameter orca, raydium and uniswap already implemented. Meteora returns its bins unconditionally, so the gap looked like "only Meteora has bins" rather than "nobody can ask". The route now accepts `binCount` and forwards it, and the parameter is threaded through hummingbot-api, the API client (1.5.8) and condor. +18. **PancakeSwap had no bin support at all.** Added, reusing the V3 tick walk. The two SDKs disagree on numeric type — `@uniswap/v3-sdk` is JSBI-based, `@pancakeswap/v3-sdk` uses native `bigint` — so the walk moved to a shared `clmm-v3-utils` helper that works in `bigint`, with each connector adapting its own SDK rather than one importing the other's math. +19. **PancakeSwap reported virtual liquidity as token amounts.** Both `baseTokenAmount` and `quoteTokenAmount` came from `pool.liquidity` — V3's active liquidity in sqrt-price space, not a token quantity — scaled by each token's decimals, so the same meaningless figure was reported for both sides. Now ERC20 `balanceOf` on the pool contract, the fix Uniswap already carried. +20. **Raydium bypassed Gateway entirely.** hummingbot-api's pool-info special-cased Raydium: it skipped Gateway, called `api-v3.raydium.io` directly, and reshaped that response to imitate Gateway's. The transform hardcoded `active_bin_id` to `None`, `bin_step` to `1` and `bins` to `[]`, so Raydium silently returned degraded data and could not answer `binCount` at all. It now takes the same path as every other CLMM connector. +21. **Two default RPC endpoints were dead.** `eth.llamarpc.com` and `binance.llamarpc.com` answer nothing; Gateway logged "Unable to fetch block number" at startup and every read on those chains failed — which is how a PancakeSwap pool that plainly exists reported "Pool not found". Defaults are now `eth-mainnet.g.alchemy.com/public` and `bsc-dataseed.bnbchain.org`. + +--- + +## 3. The architecture + +### 3.1 Why close retries and open does not + +| Operation | If the outcome is uncertain and you blindly re-submit… | Blind-retry safe? | +|---|---|---| +| Swap | Second swap also executes → double spend | ❌ | +| Open position | Second position minted → duplicate exposure | ❌ | +| **Close position** | Success consumes the position account; a second close fails cleanly with "position not found" | ✅ idempotent | + +The same price move that makes a close fail (stale withdrawal minimums) also makes an open fail (stale deposit maximums), but the recovery is asymmetric: + +- **Close**: the intent — "remove whatever is in this position" — stays valid at any price. Retry with a fresh quote, at the connector (fast path) and the executor (paced re-entry). +- **Open**: the intent — a range and a base/quote split computed at the old price — is stale. The executor fails cleanly with nothing on-chain (`FAILED`), and the **controller** re-decides: the next cycle recomputes bounds around the current price and creates a fresh executor. (One guard: if the add actually landed and only the bookkeeping after it threw, the executor flips to `CLOSING` to recover the funds instead of stranding them.) + +### 3.2 The close lifecycle + +```mermaid +sequenceDiagram + participant EX as LP executor + participant CN as Connector + participant GW as Gateway + participant SOL as Chain + + loop each CLOSING re-entry (≤ max_retries + 1, backoff 2ⁿ s cap 30 s) + EX->>CN: get_position_info_fresh (uncached) + alt position definitively absent (seen on-chain before + 3 fresh misses) + EX->>EX: already closed → COMPLETE + else position live or read transiently failed + EX->>CN: close position + CN->>GW: POST close-position (one request — max_retries=0) + GW->>SOL: fetch fresh state → quote → build → simulate + alt simulation fails + GW-->>CN: typed 400, pre-broadcast — no fee spent + else + GW->>SOL: broadcast + confirm + GW-->>CN: CONFIRMED or typed error + end + CN-->>EX: result or typed exception + end + end + Note over EX: budget exhausted, position still live →
involuntary POSITION_HOLD (hold_reason: close_retries_exhausted,
zero-amount marker order carrying position_address) +``` + +### 3.3 Terminal semantics + +```mermaid +stateDiagram-v2 + [*] --> OPENING + OPENING --> IN_RANGE: position minted + OPENING --> FAILED: open failed, nothing on-chain
(controller re-decides with fresh bounds) + OPENING --> CLOSING: add landed but bookkeeping threw
(recover the funds) + IN_RANGE --> CLOSING: limit price hit / stop requested + OUT_OF_RANGE --> CLOSING: limit price hit / stop requested + IN_RANGE --> OUT_OF_RANGE + OUT_OF_RANGE --> IN_RANGE + CLOSING --> CLOSING: close attempt failed,
budget remains — backoff, re-enter + CLOSING --> COMPLETE: closed (or confirmed already closed) + CLOSING --> POSITION_HOLD: budget exhausted, position live —
hold_reason set, marker order carries address + POSITION_HOLD --> [*] + COMPLETE --> [*] + FAILED --> [*] +``` + +- **`FAILED`** = abnormal end with **nothing left on-chain**. The controller may create a replacement. +- **`POSITION_HOLD` + `hold_reason`** = residual exposure that could not be closed. Voluntary holds (`keep_position=True`) and involuntary holds are the same close type, distinguished only by the reason. The zero-amount marker order makes the hold visible to the durable hold store without corrupting spot accounting (a still-open position's pool balances are not returned tokens). +- Consumers key off `hold_reason`, not close type: the rebalancer halts and skips accounting; the API's orphan listing is a view over involuntary holds; condor warns the agent. +- Exception: a **force-stop** with a live position still terminates `FAILED` — the close may be in flight at the shutdown deadline, so the legacy `FAILED`-with-position orphan class covers it. + +### 3.4 Two topologies, one orphan lifecycle + +The executor is the only layer present in both deployment topologies, which is why the bounded close re-entry lives there. + +```mermaid +flowchart TB + subgraph TA["Topology A — deployed bot"] + A0["lp_rebalancer controller"] --> A1["ExecutorOrchestrator"] --> A2["LPExecutor"] + end + subgraph TB2["Topology B — manage_executors (condor / API)"] + B0["condor agent / API user"] --> B1["manage_executors MCP tool"] --> B2["hummingbot-api ExecutorService
(no controller — controller_id is a label)"] --> B3["LPExecutor"] + end + A2 --> GW["Gateway"] + B3 --> GW +``` + +When a close exhausts its budget: + +```mermaid +flowchart TD + T["Executor terminates POSITION_HOLD
hold_reason: close_retries_exhausted"] --> P["hummingbot-api persists final state
with orphan shape"] + T --> H["Controller (topology A) halts:
no new positions over the orphan"] + P --> L["GET /executors/positions/orphaned
lists the candidate"] + P --> W["condor tick prompt warns the agent"] + L --> R["Operator/agent closes the position
via gateway tools (remove liquidity by address —
a fresh lp_executor CANNOT adopt it)"] + R --> S["POST /executors/{id}/resolve-orphan
clears the listing and warnings"] + S --> X["Restart the controller —
its halt is in-memory; restart is the
acknowledgment that recovery is done"] +``` + +Also listed as orphan candidates: legacy `FAILED` executors whose final state carries a position address (force-stop stragglers), and `SYSTEM_CLEANUP` LP executors from an API restart (position address unknown — reconcile against on-chain positions). + +### 3.5 Transaction status polling + +Both chains' `/poll` routes share one `TransactionStatusCode` contract; the connector's 1 s poller acts on it: + +| `txStatus` | Meaning | Connector behavior | +|---|---|---| +| `1` CONFIRMED | Landed without error | Fill the order | +| `0` PENDING | The chain has seen the transaction (Solana: signature status; EVM: in the mempool) | Keep polling — it can still confirm (unbounded by design) | +| `-1` FAILED | Landed with an error / reverted | Fail the order, trigger `TransactionFailure` | +| `-2` NOT_FOUND | Unknown to the chain — never received or dropped (Solana: terminal once the blockhash expires; EVM: the mempool is visible via `getTransaction`, so not-found already means dropped) | Transient while the order is younger than 120 s (blockhash validity margin); afterwards each miss counts toward the tracker's lost-order limit → order fails after repeated consecutive misses | + +Transient poll errors (RPC failures) report `PENDING`, never `NOT_FOUND` — an unknown outcome is a reason to poll again, not to give up. Note the LP flow's writes do not depend on this poller to progress: the connector's operation calls only return a signature after in-request confirmation. The bound matters for flows that record a hash at submission time, and for the RPC cost of stuck orders. + +### 3.6 CLMM pool-info and bins + +One route answers for every CLMM connector, and every caller reaches it through the same chain: + +```mermaid +flowchart LR + A["condor
manage_gateway_clmm(bin_count=N)"] --> B["hummingbot-api-client 1.5.8
get_pool_info(bin_count=N)"] + B --> C["hummingbot-api
GET /gateway/clmm/pool-info?bin_count=N"] + C --> D["gateway
GET /trading/clmm/pool-info?binCount=N"] + D --> E["connector pool-info
bins computed only when asked"] +``` + +`binCount` is a **request for work, not a formatting flag** — the bins are read from chain state, so the default of `0` exists to keep the common call cheap: + +| Connector | Bins | Cost of `binCount = N` | +|---|---|---| +| Meteora | Always returned; ignores `binCount` | Already paid — the DLMM pool exposes its bins directly | +| Orca | On request | Roughly flat: one position fetch, then local math | +| Raydium | On request | Roughly flat: tick-array fetch, then local math | +| Uniswap | On request | Linear: `N + 1` parallel `pool.ticks()` eth_calls | +| PancakeSwap | On request | Linear: `N + 1` parallel `pool.ticks()` eth_calls | + +Gateway caps `binCount` at 401. Because the EVM cost scales with `N` while the Solana cost does not, callers that render a depth column should treat the window size as a parameter rather than a constant. + +The output shape mirrors Meteora's `bins[]` for every connector — `{ binId, price, baseTokenAmount, quoteTokenAmount }` — and is centred on the active tick: bins below the active price hold only quote, bins above hold only base, and the one bin straddling it holds both. + +--- + +## 4. Accepted residual risks + +- **The controller halt is process-local.** A bot restart clears `_orphaned_position_address` and the rebalancer can reopen while the orphan is unresolved; conversely, resolving the orphan does not un-halt a running controller (restart is the acknowledgment step). Controllers have no persisted state to rebuild the latch from; the durable guard is the API-layer orphan record, listing, and agent warnings. +- **Force-stop stragglers.** A force-stop does not cancel an in-flight close; a lingering attempt can land after the executor is persisted with the orphan shape, making the record stale-wrong. `resolve-orphan` (after an on-chain check) is the correction path. +- **Deployment coupling.** hummingbot-api runs the *installed* hummingbot wheel — the executor- and connector-level fixes reach topology B only after the wheel is rebuilt and reinstalled. +- **`SYSTEM_CLEANUP` orphans have no position address** (no final state was persisted); they are listed for on-chain reconciliation rather than resolved automatically. +- **A lost-order verdict can race a very late confirmation.** An order failed via the NOT_FOUND deadline could in principle confirm afterwards if an RPC node was more than two minutes behind the cluster; the deadline plus the consecutive-miss requirement makes this require a broken RPC, and the alternative — waiting forever — is strictly worse. diff --git a/jest.config.js b/jest.config.js index 58a19a83e7..81d4d228ef 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,12 +20,7 @@ module.exports = { ], modulePathIgnorePatterns: ['/dist/'], setupFilesAfterEnv: ['/test/jest-setup.js'], - testPathIgnorePatterns: [ - '/node_modules/', - 'test-helpers', - '/test-scripts/', - '/test/lifecycle/', - ], + testPathIgnorePatterns: ['/node_modules/', 'test-helpers', '/test-scripts/', '/test/lifecycle/'], testMatch: ['/test/**/*.test.ts', '/test/**/*.test.js'], transform: { '^.+\\.tsx?$': 'ts-jest', diff --git a/openapi.json b/openapi.json index dec2716d23..9f76a841b0 100644 --- a/openapi.json +++ b/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Hummingbot Gateway", "description": "API endpoints for interacting with DEXs and blockchains", - "version": "dev-2.11.0" + "version": "dev-2.17.0" }, "components": { "parameters": { @@ -15,9769 +15,3788 @@ } } }, - "schemas": {} - }, - "paths": { - "/config/": { - "get": { - "tags": ["/config"], - "description": "Get configuration settings. Returns all configurations if no parameters are specified. Use namespace to get a specific config (e.g., server, ethereum-mainnet, solana-mainnet-beta, uniswap).", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "server": { - "value": "server" - }, - "ethereum-mainnet": { - "value": "ethereum-mainnet" - }, - "solana-mainnet-beta": { - "value": "solana-mainnet-beta" - }, - "uniswap": { - "value": "uniswap" - } - }, - "in": "query", - "name": "namespace", - "required": false, - "description": "Optional configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - } - } - }, - "/config/update": { - "post": { - "tags": ["/config"], - "description": "Update a specific configuration value", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "namespace": { - "description": "Configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")", - "type": "string", - "example": "server" - }, - "path": { - "description": "Configuration path within the namespace (e.g., \"nodeURL\", \"manualGasPrice\")", - "type": "string", - "example": "nodeURL" - }, - "value": { - "description": "Configuration value", - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "object", - "properties": {} - }, - { - "type": "array", - "items": {} - } - ] - } - }, - "required": ["namespace", "path", "value"] - }, - "examples": { - "example1": { - "value": { - "namespace": "solana-mainnet-beta", - "path": "maxFee", - "value": 0.01 - } - }, - "example2": { - "value": { - "namespace": "ethereum-mainnet", - "path": "nodeURL", - "value": "https://eth-mainnet.g.alchemy.com/v2/your-api-key" - } - }, - "example3": { - "value": { - "namespace": "ethereum-mainnet", - "path": "gasLimitTransaction", - "value": 3000000 - } - }, - "example4": { - "value": { - "namespace": "solana-devnet", - "path": "retryCount", - "value": 5 - } - }, - "example5": { - "value": { - "namespace": "server", - "path": "port", - "value": 15888 - } - } - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "description": "Status message", - "type": "string" - } - }, - "required": ["message"] - } - } - } - } - } - } - }, - "/config/chains": { - "get": { - "tags": ["/config"], - "description": "Returns a list of available blockchain networks supported by Gateway.", - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chains": { - "type": "array", - "items": { - "type": "object", - "properties": { - "chain": { - "type": "string" - }, - "networks": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["chain", "networks"] - } - } - }, - "required": ["chains"] - } - } - } - } - } - } - }, - "/config/connectors": { - "get": { - "tags": ["/config"], - "description": "Returns a list of available DEX connectors and their supported blockchain networks.", - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connectors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "trading_types": { - "type": "array", - "items": { - "type": "string" - } - }, - "chain": { - "type": "string" - }, - "networks": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["name", "trading_types", "chain", "networks"] - } - } - }, - "required": ["connectors"] - } - } - } - } - } - } - }, - "/config/namespaces": { - "get": { - "tags": ["/config"], - "description": "Returns a list of all configuration namespaces available in Gateway.", - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "namespaces": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["namespaces"] - } - } - } - } - } - } - }, - "/wallet/": { - "get": { - "tags": ["/wallet"], - "description": "Get all wallets across different chains", - "parameters": [ - { - "schema": { - "default": true, - "type": "boolean" - }, - "in": "query", - "name": "showHardware", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain name", - "type": "string", - "example": "solana" - }, - "walletAddresses": { - "description": "List of regular wallet addresses with private keys", - "type": "array", - "items": { - "description": "Wallet address (Ethereum format: 0x... or Solana format: base58)", - "type": "string" - } - }, - "hardwareWalletAddresses": { - "description": "List of hardware wallet addresses (Ledger)", - "type": "array", - "items": { - "description": "Wallet address (Ethereum format: 0x... or Solana format: base58)", - "type": "string" - } - } - }, - "required": ["chain", "walletAddresses"] - } - } - } - } - } - } - } - }, - "/wallet/add": { - "post": { - "tags": ["/wallet"], - "description": "Add a new wallet using a private key", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain to add wallet to", - "enum": ["ethereum", "solana"], - "type": "string", - "example": "solana" - }, - "privateKey": { - "description": "Private key for the wallet", - "type": "string", - "example": "\u003Cyour-private-key\u003E" - }, - "setDefault": { - "description": "Set this wallet as the default for the chain", - "default": false, - "type": "boolean" - } - }, - "required": ["chain", "privateKey"] - }, - "example": { - "chain": "solana", - "privateKey": "\u003Cyour-private-key\u003E", - "setDefault": true - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "description": "The wallet address that was added", - "type": "string" - } - }, - "required": ["address"] - } - } - } - } - } - } - }, - "/wallet/add-hardware": { - "post": { - "tags": ["/wallet"], - "description": "Add a hardware wallet", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain for hardware wallet", - "enum": ["ethereum", "solana"], - "default": "solana", - "type": "string", - "example": "solana" - }, - "address": { - "description": "Hardware wallet address to add (must exist on connected Ledger device)", - "type": "string" - }, - "setDefault": { - "description": "Set this wallet as the default for the chain", - "default": false, - "type": "boolean" - } - }, - "required": ["chain", "address"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "description": "The hardware wallet address that was added", - "type": "string" - }, - "publicKey": { - "description": "Public key of the hardware wallet", - "type": "string" - }, - "derivationPath": { - "description": "BIP32/BIP44 derivation path used", - "type": "string" - }, - "message": { - "description": "Success message", - "type": "string" - } - }, - "required": ["address", "publicKey", "derivationPath", "message"] - } - } - } - } - } - } - }, - "/wallet/remove": { - "delete": { - "tags": ["/wallet"], - "description": "Remove a wallet by its address (automatically detects wallet type)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain to remove wallet from", - "enum": ["ethereum", "solana"], - "type": "string", - "example": "solana" - }, - "address": { - "description": "Wallet address to remove", - "type": "string" - } - }, - "required": ["chain", "address"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "description": "Success message indicating wallet type removed", - "type": "string" - } - }, - "required": ["message"] - } - } - } - } - } - } - }, - "/wallet/setDefault": { - "post": { - "tags": ["/wallet"], - "description": "Set a wallet as default for a specific chain", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain to set default wallet for", - "enum": ["ethereum", "solana"], - "type": "string", - "example": "solana" - }, - "address": { - "description": "Wallet address to set as default", - "type": "string" - } - }, - "required": ["chain", "address"] - }, - "examples": { - "example1": { - "value": { - "chain": "ethereum", - "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f2BDf8" - } - }, - "example2": { - "value": { - "chain": "solana", - "address": "7UX2i7SucgLMQcfZ75s3VXmZZY4YRUyJN9X1RgfMoDUi" - } - } - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "description": "Success message", - "type": "string" - }, - "chain": { - "description": "Chain name", - "type": "string" - }, - "address": { - "description": "Default wallet address", - "type": "string" - } - }, - "required": ["message", "chain", "address"] - } - } - } - } - } - } - }, - "/tokens/{symbolOrAddress}": { - "get": { - "tags": ["/tokens"], - "description": "Get a specific token by symbol or address", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "ethereum": { - "value": "ethereum" - }, - "solana": { - "value": "solana" - } - }, - "in": "query", - "name": "chain", - "required": true, - "description": "Blockchain network (e.g., ethereum, solana)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - }, - "devnet": { - "value": "devnet" - } - }, - "in": "query", - "name": "network", - "required": true, - "description": "Network name (e.g., mainnet, mainnet-beta)" - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "symbolOrAddress", - "required": true, - "description": "Token symbol or address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "token": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - } - }, - "required": ["name", "symbol", "address", "decimals"] - }, - "chain": { - "type": "string" - }, - "network": { - "type": "string" - } - }, - "required": ["token", "chain", "network"] - } - } - } - } - } - } - }, - "/tokens/find/{address}": { - "get": { - "tags": ["/tokens"], - "description": "Get token information with market data from GeckoTerminal by address", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "solana-mainnet-beta": { - "value": "solana-mainnet-beta" - }, - "ethereum-mainnet": { - "value": "ethereum-mainnet" - }, - "ethereum-base": { - "value": "ethereum-base" - }, - "ethereum-polygon": { - "value": "ethereum-polygon" - } - }, - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "So11111111111111111111111111111111111111112": { - "value": "So11111111111111111111111111111111111111112" - }, - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { - "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - } - }, - "in": "path", - "name": "address", - "required": true, - "description": "Token contract address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - }, - "geckoData": { - "type": "object", - "allOf": [ - { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - }, - { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - } - ] - } - }, - "required": ["name", "symbol", "address", "decimals"] - } - } - } - } - } - } - }, - "/tokens/": { - "get": { - "tags": ["/tokens"], - "description": "List tokens from token lists with optional filtering", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "ethereum": { - "value": "ethereum" - }, - "solana": { - "value": "solana" - } - }, - "in": "query", - "name": "chain", - "required": false, - "description": "Blockchain network (e.g., ethereum, solana)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - }, - "devnet": { - "value": "devnet" - } - }, - "in": "query", - "name": "network", - "required": false, - "description": "Network name (e.g., mainnet, mainnet-beta)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "USDC": { - "value": "USDC" - }, - "USD": { - "value": "USD" - } - }, - "in": "query", - "name": "search", - "required": false, - "description": "Search term for filtering tokens by symbol or name" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - } - }, - "required": ["name", "symbol", "address", "decimals"] - } - } - }, - "required": ["tokens"] - } - } - } - } - } - }, - "post": { - "tags": ["/tokens"], - "description": "Add a new token to a token list", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain network (e.g., ethereum, solana)", - "type": "string", - "example": "ethereum" - }, - "network": { - "description": "Network name (e.g., mainnet, mainnet-beta)", - "type": "string", - "example": "mainnet" - }, - "token": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - } - }, - "required": ["name", "symbol", "address", "decimals"] - } - }, - "required": ["chain", "network", "token"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "description": "Success message", - "type": "string" - }, - "requiresRestart": { - "description": "Whether gateway restart is required", - "default": true, - "type": "boolean" - } - }, - "required": ["message", "requiresRestart"] - } - } - } - } - } - } - }, - "/tokens/save/{address}": { - "post": { - "tags": ["/tokens"], - "description": "Find token from GeckoTerminal and save it to the token list", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "solana-mainnet-beta": { - "value": "solana-mainnet-beta" - }, - "ethereum-mainnet": { - "value": "ethereum-mainnet" - }, - "ethereum-base": { - "value": "ethereum-base" - }, - "ethereum-polygon": { - "value": "ethereum-polygon" - } - }, - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "So11111111111111111111111111111111111111112": { - "value": "So11111111111111111111111111111111111111112" - }, - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { - "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - } - }, - "in": "path", - "name": "address", - "required": true, - "description": "Token contract address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "token": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - } - }, - "required": ["name", "symbol", "address", "decimals"] - } - }, - "required": ["message", "token"] - } - } - } - } - } - } - }, - "/tokens/{address}": { - "delete": { - "tags": ["/tokens"], - "description": "Remove a token from a token list by address", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "ethereum": { - "value": "ethereum" - }, - "solana": { - "value": "solana" - } - }, - "in": "query", - "name": "chain", - "required": true, - "description": "Blockchain network (e.g., ethereum, solana)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - }, - "devnet": { - "value": "devnet" - } - }, - "in": "query", - "name": "network", - "required": true, - "description": "Network name (e.g., mainnet, mainnet-beta)" - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "address", - "required": true, - "description": "Token address to remove" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "description": "Success message", - "type": "string" - }, - "requiresRestart": { - "description": "Whether gateway restart is required", - "default": true, - "type": "boolean" - } - }, - "required": ["message", "requiresRestart"] - } - } - } - } - } - } - }, - "/pools/{tradingPair}": { - "get": { - "tags": ["/pools"], - "description": "Get a specific pool by trading pair", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "raydium": { - "value": "raydium" - }, - "meteora": { - "value": "meteora" - }, - "uniswap": { - "value": "uniswap" - }, - "orca": { - "value": "orca" - } - }, - "in": "query", - "name": "connector", - "required": true, - "description": "Connector (raydium, meteora, uniswap, orca)" - }, - { - "schema": { - "default": "mainnet-beta", - "type": "string" - }, - "examples": { - "mainnet-beta": { - "value": "mainnet-beta" - }, - "mainnet": { - "value": "mainnet" - } - }, - "in": "query", - "name": "network", - "required": true, - "description": "Network name (mainnet, mainnet-beta, etc)" - }, - { - "schema": { - "enum": ["amm", "clmm"], - "type": "string" - }, - "examples": { - "amm": { - "value": "amm" - }, - "clmm": { - "value": "clmm" - } - }, - "in": "query", - "name": "type", - "required": true, - "description": "Pool type" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "SOL-USDC": { - "value": "SOL-USDC" - }, - "ETH-USDC": { - "value": "ETH-USDC" - } - }, - "in": "path", - "name": "tradingPair", - "required": true, - "description": "Trading pair (e.g., SOL-USDC, ETH-USDC)" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "type": { - "description": "Pool type", - "enum": ["clmm", "amm"], - "type": "string", - "example": "clmm" - }, - "network": { - "type": "string" - }, - "baseSymbol": { - "type": "string" - }, - "quoteSymbol": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "address": { - "type": "string" - } - }, - "required": [ - "type", - "network", - "baseSymbol", - "quoteSymbol", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "address" - ] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/pools/find/{address}": { - "get": { - "tags": ["/pools"], - "description": "Get detailed pool information by address from GeckoTerminal", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "solana-mainnet-beta": { - "value": "solana-mainnet-beta" - }, - "ethereum-mainnet": { - "value": "ethereum-mainnet" - }, - "ethereum-base": { - "value": "ethereum-base" - }, - "ethereum-polygon": { - "value": "ethereum-polygon" - } - }, - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2": { - "value": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" - }, - "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640": { - "value": "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" - } - }, - "in": "path", - "name": "address", - "required": true, - "description": "Pool contract address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "type": { - "description": "Pool type", - "enum": ["clmm", "amm"], - "type": "string", - "example": "clmm" - }, - "network": { - "type": "string" - }, - "baseSymbol": { - "type": "string" - }, - "quoteSymbol": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "address": { - "type": "string" - }, - "geckoData": { - "type": "object", - "properties": { - "volumeUsd24h": { - "description": "24-hour trading volume in USD", - "type": "string" - }, - "liquidityUsd": { - "description": "Total liquidity in USD", - "type": "string" - }, - "priceNative": { - "description": "Base token price in quote token", - "type": "string" - }, - "priceUsd": { - "description": "Base token price in USD", - "type": "string" - }, - "buys24h": { - "description": "Number of buy transactions in 24h", - "type": "number" - }, - "sells24h": { - "description": "Number of sell transactions in 24h", - "type": "number" - }, - "apr": { - "description": "Annual percentage rate", - "type": "number" - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "volumeUsd24h", - "liquidityUsd", - "priceNative", - "priceUsd", - "buys24h", - "sells24h", - "timestamp" - ] - } - }, - "required": [ - "type", - "network", - "baseSymbol", - "quoteSymbol", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "address" - ] - } - } - } - } - } - } - }, - "/pools/find": { - "get": { - "tags": ["/pools"], - "description": "Find pools for a token pair from GeckoTerminal", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "solana-mainnet-beta": { - "value": "solana-mainnet-beta" - }, - "ethereum-mainnet": { - "value": "ethereum-mainnet" - }, - "ethereum-base": { - "value": "ethereum-base" - }, - "ethereum-polygon": { - "value": "ethereum-polygon" - } - }, - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "raydium": { - "value": "raydium" - }, - "meteora": { - "value": "meteora" - }, - "uniswap": { - "value": "uniswap" - }, - "pancakeswap": { - "value": "pancakeswap" - }, - "pancakeswap-sol": { - "value": "pancakeswap-sol" - }, - "orca": { - "value": "orca" - } - }, - "in": "query", - "name": "connector", - "required": false, - "description": "Filter by connector name (e.g., raydium, meteora, uniswap, pancakeswap, pancakeswap-sol)" - }, - { - "schema": { - "enum": ["clmm", "amm"], - "default": "clmm", - "type": "string" - }, - "examples": { - "clmm": { - "value": "clmm" - }, - "amm": { - "value": "amm" - } - }, - "in": "query", - "name": "type", - "required": false, - "description": "Filter by pool type: clmm (v3-style concentrated liquidity) or amm (v2-style)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "SOL": { - "value": "SOL" - }, - "So11111111111111111111111111111111111111112": { - "value": "So11111111111111111111111111111111111111112" - }, - "USDC": { - "value": "USDC" - } - }, - "in": "query", - "name": "tokenA", - "required": false, - "description": "First token symbol or contract address (optional - for filtering by token pair)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "USDC": { - "value": "USDC" - }, - "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { - "value": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - }, - "SOL": { - "value": "SOL" - } - }, - "in": "query", - "name": "tokenB", - "required": false, - "description": "Second token symbol or contract address (optional - for filtering by token pair)" - }, - { - "schema": { - "minimum": 1, - "maximum": 10, - "default": 10, - "type": "number" - }, - "in": "query", - "name": "pages", - "required": false, - "description": "Number of pages to fetch from GeckoTerminal (1-10, default: 10)" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "description": "Pool type", - "enum": ["clmm", "amm"], - "type": "string", - "example": "clmm" - }, - "network": { - "type": "string" - }, - "baseSymbol": { - "type": "string" - }, - "quoteSymbol": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "address": { - "type": "string" - }, - "geckoData": { - "type": "object", - "properties": { - "volumeUsd24h": { - "description": "24-hour trading volume in USD", - "type": "string" - }, - "liquidityUsd": { - "description": "Total liquidity in USD", - "type": "string" - }, - "priceNative": { - "description": "Base token price in quote token", - "type": "string" - }, - "priceUsd": { - "description": "Base token price in USD", - "type": "string" - }, - "buys24h": { - "description": "Number of buy transactions in 24h", - "type": "number" - }, - "sells24h": { - "description": "Number of sell transactions in 24h", - "type": "number" - }, - "apr": { - "description": "Annual percentage rate", - "type": "number" - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "volumeUsd24h", - "liquidityUsd", - "priceNative", - "priceUsd", - "buys24h", - "sells24h", - "timestamp" - ] - } - }, - "required": [ - "type", - "network", - "baseSymbol", - "quoteSymbol", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "address" - ] - } - } - } - } - } - } - } - }, - "/pools/": { - "get": { - "tags": ["/pools"], - "description": "List all pools for a connector, optionally filtered by network, type, or search term", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "raydium": { - "value": "raydium" - }, - "meteora": { - "value": "meteora" - }, - "uniswap": { - "value": "uniswap" - }, - "orca": { - "value": "orca" - } - }, - "in": "query", - "name": "connector", - "required": true, - "description": "Connector (raydium, meteora, uniswap, orca)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - }, - "base": { - "value": "base" - } - }, - "in": "query", - "name": "network", - "required": false, - "description": "Optional: filter by network (mainnet, mainnet-beta, etc)" - }, - { - "schema": { - "enum": ["clmm", "amm"], - "type": "string" - }, - "examples": { - "clmm": { - "value": "clmm" - }, - "amm": { - "value": "amm" - } - }, - "in": "query", - "name": "type", - "required": false, - "description": "Optional: filter by pool type" - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "search", - "required": false, - "description": "Optional: search by token symbol or address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "description": "Pool type", - "enum": ["clmm", "amm"], - "type": "string", - "example": "clmm" - }, - "network": { - "type": "string" - }, - "baseSymbol": { - "type": "string" - }, - "quoteSymbol": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "address": { - "type": "string" - } - }, - "required": [ - "type", - "network", - "baseSymbol", - "quoteSymbol", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "address" - ] - } - } - } - } - } - } - }, - "post": { - "tags": ["/pools"], - "description": "Add a new pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connector": { - "description": "Connector (raydium, meteora, uniswap, orca)", - "type": "string", - "example": "raydium" - }, - "type": { - "description": "Pool type", - "enum": ["clmm", "amm"], - "type": "string", - "example": "clmm" - }, - "network": { - "description": "Network name (mainnet, mainnet-beta, etc)", - "default": "mainnet-beta", - "type": "string", - "example": "mainnet-beta" - }, - "address": { - "description": "Pool contract address", - "type": "string" - }, - "baseSymbol": { - "description": "Base token symbol", - "type": "string", - "example": "SOL" - }, - "quoteSymbol": { - "description": "Quote token symbol", - "type": "string", - "example": "USDC" - }, - "baseTokenAddress": { - "description": "Base token contract address", - "type": "string", - "example": "So11111111111111111111111111111111111111112" - }, - "quoteTokenAddress": { - "description": "Quote token contract address", - "type": "string", - "example": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - }, - "feePct": { - "description": "Pool fee percentage (optional - fetched from pool-info if not provided)", - "minimum": 0, - "maximum": 100, - "type": "number", - "example": 0.25 - } - }, - "required": [ - "connector", - "type", - "network", - "address", - "baseSymbol", - "quoteSymbol", - "baseTokenAddress", - "quoteTokenAddress" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/pools/save/{address}": { - "post": { - "tags": ["/pools"], - "description": "Find pool from GeckoTerminal and save it to the pool list", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "solana-mainnet-beta": { - "value": "solana-mainnet-beta" - }, - "ethereum-mainnet": { - "value": "ethereum-mainnet" - }, - "ethereum-base": { - "value": "ethereum-base" - }, - "ethereum-polygon": { - "value": "ethereum-polygon" - } - }, - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2": { - "value": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" - }, - "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640": { - "value": "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" - } - }, - "in": "path", - "name": "address", - "required": true, - "description": "Pool contract address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "pool": { - "type": "object", - "properties": { - "type": { - "description": "Pool type", - "enum": ["clmm", "amm"], - "type": "string", - "example": "clmm" - }, - "network": { - "type": "string" - }, - "baseSymbol": { - "type": "string" - }, - "quoteSymbol": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "address": { - "type": "string" - }, - "geckoData": { - "type": "object", - "properties": { - "volumeUsd24h": { - "description": "24-hour trading volume in USD", - "type": "string" - }, - "liquidityUsd": { - "description": "Total liquidity in USD", - "type": "string" - }, - "priceNative": { - "description": "Base token price in quote token", - "type": "string" - }, - "priceUsd": { - "description": "Base token price in USD", - "type": "string" - }, - "buys24h": { - "description": "Number of buy transactions in 24h", - "type": "number" - }, - "sells24h": { - "description": "Number of sell transactions in 24h", - "type": "number" - }, - "apr": { - "description": "Annual percentage rate", - "type": "number" - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "volumeUsd24h", - "liquidityUsd", - "priceNative", - "priceUsd", - "buys24h", - "sells24h", - "timestamp" - ] - } - }, - "required": [ - "type", - "network", - "baseSymbol", - "quoteSymbol", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "address" - ] - } - }, - "required": ["message", "pool"] - } - } - } - } - } - } - }, - "/pools/{address}": { - "delete": { - "tags": ["/pools"], - "description": "Remove a pool by address", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "raydium": { - "value": "raydium" - }, - "meteora": { - "value": "meteora" - }, - "uniswap": { - "value": "uniswap" - }, - "orca": { - "value": "orca" - } - }, - "in": "query", - "name": "connector", - "required": true, - "description": "Connector (raydium, meteora, uniswap, orca)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - } - }, - "in": "query", - "name": "network", - "required": true, - "description": "Network name (mainnet, mainnet-beta, etc)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "amm": { - "value": "amm" - }, - "clmm": { - "value": "clmm" - } - }, - "in": "query", - "name": "type", - "required": true, - "description": "Pool type" - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "address", - "required": true, - "description": "Pool contract address to remove" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/trading/swap/quote": { - "get": { - "tags": ["/trading/swap"], - "description": "Get a swap quote for any supported chain", - "parameters": [ - { - "schema": { - "default": "solana-mainnet-beta", - "type": "string" - }, - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet, ethereum-polygon)" - }, - { - "schema": { - "default": "jupiter/router", - "type": "string" - }, - "in": "query", - "name": "connector", - "required": false, - "description": "Connector to use in format: connector/type (e.g., jupiter/router, raydium/amm, uniswap/clmm). If not provided, uses network's configured swapProvider" - }, - { - "schema": { - "default": "SOL", - "type": "string" - }, - "in": "query", - "name": "baseToken", - "required": true, - "description": "Symbol or address of the base token" - }, - { - "schema": { - "default": "USDC", - "type": "string" - }, - "in": "query", - "name": "quoteToken", - "required": true, - "description": "Symbol or address of the quote token" - }, - { - "schema": { - "default": 1, - "type": "number" - }, - "in": "query", - "name": "amount", - "required": true, - "description": "Amount to swap" - }, - { - "schema": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "in": "query", - "name": "side", - "required": true, - "description": "Side of the swap" - }, - { - "schema": { - "default": 1, - "type": "number" - }, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Slippage tolerance percentage (optional)" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "poolAddress": { - "description": "Pool address for AMM/CLMM swaps", - "type": "string" - }, - "routePath": { - "description": "Route path for router-based swaps", - "type": "string" - }, - "slippagePct": { - "description": "Slippage tolerance percentage", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn" - ] - } - } - } - } - } - } - }, - "/trading/swap/execute": { - "post": { - "tags": ["/trading/swap"], - "description": "Execute a swap on any supported chain", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address to execute swap from", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet, ethereum-polygon)", - "default": "solana-mainnet-beta", - "type": "string" - }, - "connector": { - "description": "Connector to use in format: connector/type (e.g., jupiter/router, raydium/amm, uniswap/clmm). If not provided, uses network's configured swapProvider", - "default": "jupiter/router", - "type": "string" - }, - "baseToken": { - "description": "Symbol or address of the base token", - "default": "SOL", - "type": "string" - }, - "quoteToken": { - "description": "Symbol or address of the quote token", - "default": "USDC", - "type": "string" - }, - "amount": { - "description": "Amount to swap", - "default": 1, - "type": "number" - }, - "side": { - "description": "Side of the swap", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "slippagePct": { - "description": "Slippage tolerance percentage (optional)", - "default": 1, - "type": "number" - } - }, - "required": ["walletAddress", "chainNetwork", "baseToken", "quoteToken", "amount", "side"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/trading/clmm/pool-info": { - "get": { - "tags": ["/trading/clmm"], - "description": "Get CLMM pool information from any supported connector", - "parameters": [ - { - "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], - "default": "meteora", - "type": "string" - }, - "example": "meteora", - "in": "query", - "name": "connector", - "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" - }, - { - "schema": { - "default": "solana-mainnet-beta", - "type": "string" - }, - "example": "solana-mainnet-beta", - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "string" - }, - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Pool contract address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ] - } - } - } - } - } - } - }, - "/trading/clmm/position-info": { - "get": { - "tags": ["/trading/clmm"], - "description": "Get CLMM position information from any supported connector", - "parameters": [ - { - "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], - "default": "meteora", - "type": "string" - }, - "example": "meteora", - "in": "query", - "name": "connector", - "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" - }, - { - "schema": { - "default": "solana-mainnet-beta", - "type": "string" - }, - "example": "solana-mainnet-beta", - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "string" - }, - "example": "\u003Csample-position-address\u003E", - "in": "query", - "name": "positionAddress", - "required": true, - "description": "Position address or NFT token ID" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } - } - } - } - } - }, - "/trading/clmm/positions-owned": { - "get": { - "tags": ["/trading/clmm"], - "description": "Get all CLMM positions owned by a wallet from any supported connector", - "parameters": [ - { - "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], - "default": "meteora", - "type": "string" - }, - "example": "meteora", - "in": "query", - "name": "connector", - "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" - }, - { - "schema": { - "default": "solana-mainnet-beta", - "type": "string" - }, - "example": "solana-mainnet-beta", - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "in": "query", - "name": "walletAddress", - "required": false, - "description": "Wallet address (optional, uses default wallet if not provided)" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ], - "title": "PositionInfo" - } - } - } - } - } - } - } - }, - "/trading/clmm/quote-position": { - "get": { - "tags": ["/trading/clmm"], - "description": "Quote amounts for a new CLMM position from any supported connector", - "parameters": [ - { - "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], - "default": "meteora", - "type": "string" - }, - "example": "meteora", - "in": "query", - "name": "connector", - "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" - }, - { - "schema": { - "default": "solana-mainnet-beta", - "type": "string" - }, - "example": "solana-mainnet-beta", - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "type": "number" - }, - "example": 150, - "in": "query", - "name": "lowerPrice", - "required": true, - "description": "Lower price bound for the position" - }, - { - "schema": { - "type": "number" - }, - "example": 250, - "in": "query", - "name": "upperPrice", - "required": true, - "description": "Upper price bound for the position" - }, - { - "schema": { - "type": "string" - }, - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Pool contract address" - }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "baseTokenAmount", - "required": false, - "description": "Amount of base token to deposit" - }, - { - "schema": { - "type": "number" - }, - "example": 2, - "in": "query", - "name": "quoteTokenAmount", - "required": false, - "description": "Amount of quote token to deposit" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 1, - "type": "number" - }, - "example": 1, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] - } - } - } - } - } - } - }, - "/trading/clmm/open": { - "post": { - "tags": ["/trading/clmm"], - "description": "Open a new CLMM position across supported connectors", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "lowerPrice": { - "description": "Lower price bound for the position", - "type": "number", - "example": 150 - }, - "upperPrice": { - "description": "Upper price bound for the position", - "type": "number", - "example": 250 - }, - "poolAddress": { - "description": "Pool address", - "type": "string", - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number", - "example": 1 - } - }, - "required": ["connector", "chainNetwork", "walletAddress", "lowerPrice", "upperPrice", "poolAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/trading/clmm/add": { - "post": { - "tags": ["/trading/clmm"], - "description": "Add liquidity to an existing CLMM position across supported connectors", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "positionAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/trading/clmm/remove": { - "post": { - "tags": ["/trading/clmm"], - "description": "Remove liquidity from a CLMM position across supported connectors", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - }, - "percentageToRemove": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, - "type": "number", - "example": 100 - } - }, - "required": ["connector", "chainNetwork", "walletAddress", "positionAddress", "percentageToRemove"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/trading/clmm/collect-fees": { - "post": { - "tags": ["/trading/clmm"], - "description": "Collect fees from a CLMM position across supported connectors", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - } - }, - "required": ["connector", "chainNetwork", "walletAddress", "positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/trading/clmm/close": { - "post": { - "tags": ["/trading/clmm"], - "description": "Close a CLMM position across supported connectors", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - } - }, - "required": ["connector", "chainNetwork", "walletAddress", "positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/chains/solana/status": { - "get": { - "tags": ["/chain/solana"], - "description": "Get Solana network status", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The Solana network to use" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "type": "string" - }, - "network": { - "type": "string" - }, - "rpcUrl": { - "type": "string" - }, - "rpcProvider": { - "type": "string" - }, - "currentBlockNumber": { - "type": "number" - }, - "nativeCurrency": { - "type": "string" - }, - "swapProvider": { - "type": "string" - } - }, - "required": [ - "chain", - "network", - "rpcUrl", - "rpcProvider", - "currentBlockNumber", - "nativeCurrency", - "swapProvider" - ] - } - } - } - } - } - } - }, - "/chains/solana/estimate-gas": { - "get": { - "tags": ["/chain/solana"], - "description": "Estimate gas prices for Solana transactions", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The Solana network to use" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "feePerComputeUnit": { - "type": "number" - }, - "denomination": { - "type": "string" - }, - "computeUnits": { - "type": "number" - }, - "feeAsset": { - "type": "string" - }, - "fee": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "gasType": { - "type": "string" - }, - "maxFeePerGas": { - "type": "number" - }, - "maxPriorityFeePerGas": { - "type": "number" - } - }, - "required": ["feePerComputeUnit", "denomination", "computeUnits", "feeAsset", "fee", "timestamp"] - } - } - } - } - } - } - }, - "/chains/solana/balances": { - "post": { - "tags": ["/chain/solana"], - "description": "Get token balances for a Solana address. Only returns tokens in the network's token list. If no tokens specified or empty array provided, returns non-zero balances for tokens from the token list that are found in the wallet (includes SOL even if zero). If specific tokens are requested, returns those exact tokens with their balances, including zeros.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "address": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "tokens": { - "description": "A list of token symbols (SOL, USDC, BONK) from the network's token list. Only tokens in the token list will be returned. An empty array is treated the same as if the parameter was not provided, returning only non-zero balances (with the exception of SOL).", - "type": "array", - "items": { - "type": "string" - }, - "example": ["SOL", "USDC", "BONK"] - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Token balances for the specified address (only tokens in token list)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "balances": { - "type": "object", - "additionalProperties": { - "type": "number" - } - } - }, - "required": ["balances"], - "description": "Token balances for the specified address (only tokens in token list)" - }, - "example": { - "balances": { - "SOL": 1.5, - "USDC": 100, - "BONK": 50000 - } - } - } - } - } - } - } - }, - "/chains/solana/poll": { - "post": { - "tags": ["/chain/solana"], - "description": "Poll for the status of a Solana transaction", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "signature": { - "description": "Transaction signature to poll", - "type": "string", - "example": "55ukR6VCt1sQFMC8Nyeo51R1SMaTzUC7jikmkEJ2jjkQNdqBxXHraH7vaoaNmf8rX4Y55EXAj8XXoyzvvsrQqWZa" - }, - "tokens": { - "description": "Tokens to track balance changes for", - "type": "array", - "items": { - "type": "string" - }, - "example": ["SOL", "USDC", "BONK"] - }, - "walletAddress": { - "description": "Wallet address to track balance changes for", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - } - }, - "required": ["signature"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "currentBlock": { - "type": "number" - }, - "signature": { - "type": "string" - }, - "txBlock": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "txStatus": { - "type": "number" - }, - "fee": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "tokenBalanceChanges": { - "description": "Dictionary of token balance changes keyed by token input value (symbol or address)", - "type": "object", - "additionalProperties": { - "type": "number" - } - }, - "txData": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { - "type": "null" - } - ] - }, - "error": { - "type": "string" - } - }, - "required": ["currentBlock", "signature", "txBlock", "txStatus", "fee", "txData"] - } - } - } - } - } - } - }, - "/chains/solana/wrap": { - "post": { - "tags": ["/chain/solana"], - "description": "Wrap SOL to WSOL (Wrapped SOL)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "address": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "amount": { - "description": "The amount of SOL to wrap (in SOL, not lamports)", - "type": "string", - "example": "1.0" - } - }, - "required": ["amount"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "wrappedAddress": { - "type": "string" - }, - "nativeToken": { - "type": "string" - }, - "wrappedToken": { - "type": "string" - } - }, - "required": ["fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/chains/solana/unwrap": { - "post": { - "tags": ["/chain/solana"], - "description": "Unwrap WSOL to SOL. Note: This closes the entire WSOL account, returning all WSOL as SOL.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "address": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "amount": { - "description": "The amount of WSOL to unwrap (in SOL, not lamports). If not provided, unwraps all WSOL.", - "type": "string", - "example": "1.0" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "wrappedAddress": { - "type": "string" - }, - "nativeToken": { - "type": "string" - }, - "wrappedToken": { - "type": "string" - } - }, - "required": ["fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/chains/ethereum/status": { - "get": { - "tags": ["/chain/ethereum"], - "description": "Get Ethereum chain status", - "parameters": [ - { - "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon", "sepolia"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The Ethereum network to use" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "type": "string" - }, - "network": { - "type": "string" - }, - "rpcUrl": { - "type": "string" - }, - "rpcProvider": { - "type": "string" - }, - "currentBlockNumber": { - "type": "number" - }, - "nativeCurrency": { - "type": "string" - }, - "swapProvider": { - "type": "string" - } - }, - "required": [ - "chain", - "network", - "rpcUrl", - "rpcProvider", - "currentBlockNumber", - "nativeCurrency", - "swapProvider" - ] - } - } - } - } - } - } - }, - "/chains/ethereum/estimate-gas": { - "get": { - "tags": ["/chain/ethereum"], - "description": "Estimate gas prices for Ethereum transactions", - "parameters": [ - { - "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon", "sepolia"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The Ethereum network to use" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "feePerComputeUnit": { - "type": "number" - }, - "denomination": { - "type": "string" - }, - "computeUnits": { - "type": "number" - }, - "feeAsset": { - "type": "string" - }, - "fee": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "gasType": { - "type": "string" - }, - "maxFeePerGas": { - "type": "number" - }, - "maxPriorityFeePerGas": { - "type": "number" - } - }, - "required": ["feePerComputeUnit", "denomination", "computeUnits", "feeAsset", "fee", "timestamp"] - } - } - } - } - } - } - }, - "/chains/ethereum/balances": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Get Ethereum balances. If no tokens specified or empty array provided, returns native token (ETH) and only non-zero balances for tokens from the token list. If specific tokens are requested, returns those exact tokens with their balances, including zeros.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "tokens": { - "description": "A list of token symbols (ETH, USDC, WETH) or token addresses. Both formats are accepted and will be automatically detected. An empty array is treated the same as if the parameter was not provided, returning only non-zero balances (with the exception of ETH).", - "type": "array", - "items": { - "type": "string" - }, - "example": ["ETH", "USDC", "WETH"] - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "balances": { - "type": "object", - "additionalProperties": { - "type": "number" - } - } - }, - "required": ["balances"] - } - } - } - } - } - } - }, - "/chains/ethereum/poll": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Poll Ethereum transaction status", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], - "type": "string" - }, - "signature": { - "description": "Transaction hash to poll", - "type": "string", - "example": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - } - }, - "required": ["signature"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "currentBlock": { - "type": "number" - }, - "signature": { - "type": "string" - }, - "txBlock": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "txStatus": { - "type": "number" - }, - "fee": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "tokenBalanceChanges": { - "description": "Dictionary of token balance changes keyed by token input value (symbol or address)", - "type": "object", - "additionalProperties": { - "type": "number" - } - }, - "txData": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { - "type": "null" - } - ] - }, - "error": { - "type": "string" - } - }, - "required": ["currentBlock", "signature", "txBlock", "txStatus", "fee", "txData"] - } - } - } - } - } - } - }, - "/chains/ethereum/allowances": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Get token allowances", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "spender": { - "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address", - "type": "string", - "example": "uniswap/router" - }, - "tokens": { - "description": "Array of token symbols or addresses", - "type": "array", - "items": { - "type": "string" - }, - "example": ["USDC", "WETH"] - } - }, - "required": ["spender", "tokens"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "spender": { - "type": "string" - }, - "approvals": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["spender", "approvals"] - } - } - } - } - } - } - }, - "/chains/ethereum/approve": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Approve token spending", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "spender": { - "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address", - "type": "string", - "example": "uniswap/router" - }, - "token": { - "description": "Token symbol or address", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "The amount to approve. If not provided, defaults to maximum amount (unlimited approval).", - "default": "", - "type": "string" - } - }, - "required": ["spender", "token"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenAddress": { - "type": "string" - }, - "spender": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "nonce": { - "type": "number" - }, - "fee": { - "type": "string" - } - }, - "required": ["tokenAddress", "spender", "amount", "nonce", "fee"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/chains/ethereum/wrap": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Wrap native token to wrapped token (e.g., ETH to WETH, BNB to WBNB)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "amount": { - "description": "The amount of native token to wrap (e.g., ETH, BNB, AVAX)", - "type": "string", - "example": "0.01" - } - }, - "required": ["amount"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "nonce": { - "type": "number" - }, - "fee": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "wrappedAddress": { - "type": "string" - }, - "nativeToken": { - "type": "string" - }, - "wrappedToken": { - "type": "string" - } - }, - "required": ["nonce", "fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/chains/ethereum/unwrap": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Unwrap wrapped token to native token (e.g., WETH to ETH, WBNB to BNB)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "amount": { - "description": "The amount of wrapped token to unwrap (e.g., WETH, WBNB, WAVAX)", - "type": "string", - "example": "0.01" - } - }, - "required": ["amount"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "nonce": { - "type": "number" - }, - "fee": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "wrappedAddress": { - "type": "string" - }, - "nativeToken": { - "type": "string" - }, - "wrappedToken": { - "type": "string" - } - }, - "required": ["nonce", "fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/jupiter/router/quote-swap": { - "get": { - "tags": ["/connector/jupiter"], - "description": "Get an executable swap quote from Jupiter", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "baseToken", - "required": true, - "description": "Solana token symbol or address to determine swap direction" - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": true, - "description": "The other Solana token symbol or address in the pair" - }, - { - "schema": { - "type": "number" - }, - "example": 0.1, - "in": "query", - "name": "amount", - "required": true, - "description": "Amount of base token to trade" - }, - { - "schema": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "in": "query", - "name": "side", - "required": true, - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 1, - "type": "number" - }, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" - }, - { - "schema": { - "default": true, - "type": "boolean" - }, - "in": "query", - "name": "restrictIntermediateTokens", - "required": false, - "description": "Restrict routing through highly liquid intermediate tokens only for better price and stability" - }, - { - "schema": { - "default": false, - "type": "boolean" - }, - "in": "query", - "name": "onlyDirectRoutes", - "required": false, - "description": "Restrict routing to only go through 1 market" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "quoteId": { - "description": "Unique identifier for this quote", - "type": "string" - }, - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "quoteResponse": { - "type": "object", - "properties": { - "inputMint": { - "description": "Solana mint address of input token", - "type": "string" - }, - "inAmount": { - "description": "Input amount in token decimals", - "type": "string" - }, - "outputMint": { - "description": "Solana mint address of output token", - "type": "string" - }, - "outAmount": { - "description": "Expected output amount in token decimals", - "type": "string" - }, - "otherAmountThreshold": { - "description": "Minimum output amount based on slippage", - "type": "string" - }, - "swapMode": { - "description": "Swap mode used (ExactIn or ExactOut)", - "type": "string" - }, - "slippageBps": { - "description": "Slippage in basis points", - "type": "number" - }, - "platformFee": { - "description": "Platform fee information if applicable" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage", - "type": "string" - }, - "routePlan": { - "description": "Detailed routing plan through various markets", - "type": "array", - "items": {} - }, - "contextSlot": { - "description": "Solana slot used for quote calculation", - "type": "number" - }, - "timeTaken": { - "description": "Time taken to generate quote in milliseconds", - "type": "number" - } - }, - "required": [ - "inputMint", - "inAmount", - "outputMint", - "outAmount", - "otherAmountThreshold", - "swapMode", - "slippageBps", - "priceImpactPct", - "routePlan" - ] - }, - "approximation": { - "description": "Indicates if ExactIn approximation was used when ExactOut route was not available", - "type": "boolean" - } - }, - "required": [ - "quoteId", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn", - "quoteResponse" - ] - } - } - } - } - } - } - }, - "/connectors/jupiter/router/execute-quote": { - "post": { - "tags": ["/connector/jupiter"], - "description": "Execute a previously fetched quote from Jupiter", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "quoteId": { - "description": "ID of the Jupiter quote to execute", - "type": "string", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "priorityLevel": { - "description": "Priority level for Solana transaction processing", - "enum": ["medium", "high", "veryHigh"], - "default": "veryHigh", - "type": "string" - }, - "maxLamports": { - "description": "Maximum priority fee in lamports for Solana transaction", - "default": [1000000], - "type": "number" - } - }, - "required": ["quoteId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/jupiter/router/execute-swap": { - "post": { - "tags": ["/connector/jupiter"], - "description": "Quote and execute a token swap on Jupiter in one step", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "baseToken": { - "description": "Solana token symbol or address to determine swap direction", - "type": "string", - "example": "SOL" - }, - "quoteToken": { - "description": "The other Solana token symbol or address in the pair", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount of base token to trade", - "type": "number", - "example": 0.1 - }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number" - }, - "restrictIntermediateTokens": { - "description": "Restrict routing through highly liquid intermediate tokens only for better price and stability", - "default": true, - "type": "boolean" - }, - "onlyDirectRoutes": { - "description": "Restrict routing to only go through 1 market", - "default": false, - "type": "boolean" - }, - "priorityLevel": { - "description": "Priority level for Solana transaction processing", - "enum": ["medium", "high", "veryHigh"], - "default": "veryHigh", - "type": "string" - }, - "maxLamports": { - "description": "Maximum priority fee in lamports for Solana transaction", - "default": 1000000, - "type": "number" - } - }, - "required": ["baseToken", "quoteToken", "amount", "side"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/fetch-pools": { - "get": { - "tags": ["/connector/meteora"], - "description": "Fetch info about Meteora pools", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "minimum": 1, - "default": 10, - "type": "number" - }, - "example": 10, - "in": "query", - "name": "limit", - "required": false, - "description": "Maximum number of pools to return" - }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "tokenA", - "required": false, - "description": "First token symbol or address" - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "tokenB", - "required": false, - "description": "Second token symbol or address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ], - "title": "PoolInfo" - } - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/pool-info": { - "get": { - "tags": ["/connector/meteora"], - "description": "Get pool information for a Meteora pool", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Meteora DLMM pool address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - }, - "dynamicFeePct": { - "type": "number" - }, - "minBinId": { - "type": "number" - }, - "maxBinId": { - "type": "number" - }, - "bins": { - "type": "array", - "items": { - "type": "object", - "properties": { - "binId": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - } - }, - "required": ["binId", "price", "baseTokenAmount", "quoteTokenAmount"], - "title": "BinLiquidity" - } - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId", - "dynamicFeePct", - "minBinId", - "maxBinId", - "bins" - ] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/positions-owned": { - "get": { - "tags": ["/connector/meteora"], - "description": "Retrieve all positions owned by a user's wallet across all Meteora pools", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "in": "query", - "name": "walletAddress", - "required": true, - "description": "Solana wallet address to check for positions" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/position-info": { - "get": { - "tags": ["/connector/meteora"], - "description": "Get details for a specific Meteora position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "\u003Csample-position-address\u003E", - "in": "query", - "name": "positionAddress", - "required": true, - "description": "Position NFT address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/quote-position": { - "get": { - "tags": ["/connector/meteora"], - "description": "Quote amounts for a new Meteora CLMM position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "number" - }, - "example": 150, - "in": "query", - "name": "lowerPrice", - "required": true, - "description": "Lower price bound for the position" - }, - { - "schema": { - "type": "number" - }, - "example": 250, - "in": "query", - "name": "upperPrice", - "required": true, - "description": "Upper price bound for the position" - }, - { - "schema": { - "type": "string" - }, - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Meteora DLMM pool address" - }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "baseTokenAmount", - "required": false, - "description": "Amount of base token to deposit" - }, - { - "schema": { - "type": "number" - }, - "example": 2, - "in": "query", - "name": "quoteTokenAmount", - "required": false, - "description": "Amount of quote token to deposit" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "example": 2, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" - }, - { - "schema": { - "enum": [0, 1, 2], - "type": "number" - }, - "example": 0, - "in": "query", - "name": "strategyType", - "required": false, - "description": "Strategy type for the position" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/quote-swap": { - "get": { - "tags": ["/connector/meteora"], - "description": "Get swap quote for Meteora CLMM", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", - "in": "query", - "name": "poolAddress", - "required": false, - "description": "Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)" - }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "baseToken", - "required": true, - "description": "Token to determine swap direction" - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false, - "description": "The other token in the pair (optional - required if poolAddress not provided)" - }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "amount", - "required": true, - "description": "Amount to swap" - }, - { - "schema": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "example": "SELL", - "in": "query", - "name": "side", - "required": true, - "description": "Trade direction" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "example": 2, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" - ] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/execute-swap": { - "post": { - "tags": ["/connector/meteora"], - "description": "Execute a token swap on Meteora DLMM", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "poolAddress": { - "description": "Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)", - "type": "string", - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" - }, - "baseToken": { - "description": "Base token symbol or address", - "type": "string", - "example": "SOL" - }, - "quoteToken": { - "description": "Quote token symbol or address (optional - required if poolAddress not provided)", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount to swap", - "type": "number", - "example": 0.01 - }, - "side": { - "description": "Trade direction", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string", - "example": "SELL" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["baseToken", "amount", "side"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "baseTokenBalanceChange": { - "type": "number" - }, - "quoteTokenBalanceChange": { - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/open-position": { - "post": { - "tags": ["/connector/meteora"], - "description": "Open a new Meteora position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will open the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "lowerPrice": { - "description": "Lower price bound for the position", - "type": "number", - "example": 150 - }, - "upperPrice": { - "description": "Upper price bound for the position", - "type": "number", - "example": 250 - }, - "poolAddress": { - "description": "Meteora DLMM pool address", - "type": "string", - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - }, - "strategyType": { - "description": "Strategy type for the position", - "enum": [0, 1, 2], - "type": "number", - "example": 0 - } - }, - "required": ["lowerPrice", "upperPrice", "poolAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/add-liquidity": { - "post": { - "tags": ["/connector/meteora"], - "description": "Add liquidity to a Meteora position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will add liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - }, - "strategyType": { - "description": "Strategy type for the position", - "enum": [0, 1, 2], - "type": "number", - "example": 0 - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/remove-liquidity": { - "post": { - "tags": ["/connector/meteora"], - "description": "Remove liquidity from a Meteora position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will remove liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - }, - "liquidityPct": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, - "type": "number", - "example": 100 - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/collect-fees": { - "post": { - "tags": ["/connector/meteora"], - "description": "Collect fees from a Meteora position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will collect fees", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/close-position": { - "post": { - "tags": ["/connector/meteora"], - "description": "Close a Meteora position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will close the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/fetch-pools": { - "get": { - "tags": ["/connector/orca"], - "description": "Fetch info about Orca pools", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "minimum": 1, - "default": 10, - "type": "number" - }, - "example": 10, - "in": "query", - "name": "limit", - "required": false, - "description": "Maximum number of pools to return" - }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "tokenA", - "required": false, - "description": "First token symbol or address" - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "tokenB", - "required": false, - "description": "Second token symbol or address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ] - } - } - } - } - } - } - } - }, - "/connectors/orca/clmm/pool-info": { - "get": { - "tags": ["/connector/orca"], - "description": "Get pool information for a Orca pool", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Orca CLMM pool address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - }, - "liquidity": { - "type": "string" - }, - "sqrtPrice": { - "type": "string" - }, - "tvlUsdc": { - "type": "number" - }, - "protocolFeeRate": { - "type": "number" - }, - "yieldOverTvl": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId", - "liquidity", - "sqrtPrice", - "tvlUsdc", - "protocolFeeRate", - "yieldOverTvl" - ] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/positions-owned": { - "get": { - "tags": ["/connector/orca"], - "description": "Retrieve a list of positions owned by a user's wallet in a specific Orca pool", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "in": "query", - "name": "walletAddress", - "required": false, - "description": "Solana wallet address to check for positions" - }, - { - "schema": { - "type": "string" - }, - "example": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Orca CLMM pool address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } - } - } - } - } - } - }, - "/connectors/orca/clmm/position-info": { - "get": { - "tags": ["/connector/orca"], - "description": "Get details for a specific Orca position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "\u003Csample-position-address\u003E", - "in": "query", - "name": "positionAddress", - "required": true, - "description": "Position address" - }, - { - "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "in": "query", - "name": "walletAddress", - "required": false, - "description": "Solana wallet address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/quote-position": { - "get": { - "tags": ["/connector/orca"], - "description": "Quote amounts for a new Orca CLMM position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "number" - }, - "example": 200, - "in": "query", - "name": "lowerPrice", - "required": true, - "description": "Lower price bound for the position" - }, - { - "schema": { - "type": "number" - }, - "example": 300, - "in": "query", - "name": "upperPrice", - "required": true, - "description": "Upper price bound for the position" - }, - { - "schema": { - "type": "string" - }, - "example": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Orca CLMM pool address" - }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "baseTokenAmount", - "required": false, - "description": "Amount of base token to deposit" - }, - { - "schema": { - "type": "number" - }, - "example": 2, - "in": "query", - "name": "quoteTokenAmount", - "required": false, - "description": "Amount of quote token to deposit" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 1, - "type": "number" - }, - "example": 1, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/quote-swap": { - "get": { - "tags": ["/connector/orca"], - "description": "Get swap quote for Orca CLMM", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", - "in": "query", - "name": "poolAddress", - "required": false, - "description": "Orca CLMM pool address (optional - can be looked up from baseToken and quoteToken)" - }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "baseToken", - "required": true, - "description": "Token to determine swap direction" - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false, - "description": "The other token in the pair (optional - required if poolAddress not provided)" - }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "amount", - "required": true, - "description": "Amount to swap" - }, - { - "schema": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "example": "SELL", - "in": "query", - "name": "side", - "required": true, - "description": "Trade direction" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 1, - "type": "number" - }, - "example": 1, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" - ] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/execute-swap": { - "post": { - "tags": ["/connector/orca"], - "description": "Execute a token swap on Orca CLMM", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "poolAddress": { - "description": "Orca CLMM pool address (optional - can be looked up from baseToken and quoteToken)", - "type": "string", - "example": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" - }, - "baseToken": { - "description": "Base token symbol or address", - "type": "string", - "example": "SOL" - }, - "quoteToken": { - "description": "Quote token symbol or address (optional - required if poolAddress not provided)", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount to swap", - "type": "number", - "example": 0.01 - }, - "side": { - "description": "Trade direction", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string", - "example": "SELL" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number", - "example": 1 - } - }, - "required": ["baseToken", "amount", "side"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "baseTokenBalanceChange": { - "type": "number" - }, - "quoteTokenBalanceChange": { - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/open-position": { - "post": { - "tags": ["/connector/orca"], - "description": "Open a new Orca position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will open the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "lowerPrice": { - "description": "Lower price bound for the position", - "type": "number", - "example": 200 - }, - "upperPrice": { - "description": "Upper price bound for the position", - "type": "number", - "example": 300 - }, - "poolAddress": { - "description": "Orca CLMM pool address", - "type": "string", - "example": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number", - "example": 1 - } - }, - "required": ["lowerPrice", "upperPrice", "poolAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/add-liquidity": { - "post": { - "tags": ["/connector/orca"], - "description": "Add liquidity to an Orca position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will add liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number", - "example": 1 - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/remove-liquidity": { - "post": { - "tags": ["/connector/orca"], - "description": "Remove liquidity from an Orca position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will remove liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - }, - "liquidityPct": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, - "type": "number", - "example": 100 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number", - "example": 1 - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/collect-fees": { - "post": { - "tags": ["/connector/orca"], - "description": "Collect fees from an Orca position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will collect fees", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/orca/clmm/close-position": { - "post": { - "tags": ["/connector/orca"], - "description": "Close an Orca position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will close the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/raydium/amm/pool-info": { - "get": { - "tags": ["/connector/raydium"], - "description": "Get AMM pool information from Raydium", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Raydium AMM pool address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount" - ] - } - } - } - } - } - } - }, - "/connectors/raydium/amm/position-info": { - "get": { - "tags": ["/connector/raydium"], - "description": "Get info about a Raydium AMM position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Raydium AMM pool address" - }, - { - "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "in": "query", - "name": "walletAddress", - "required": false, - "description": "Solana wallet address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "lpTokenAmount": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "price": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "walletAddress", - "baseTokenAddress", - "quoteTokenAddress", - "lpTokenAmount", - "baseTokenAmount", - "quoteTokenAmount", - "price" - ] - } - } - } - } - } - } - }, - "/connectors/raydium/amm/quote-liquidity": { - "get": { - "tags": ["/connector/raydium"], - "description": "Quote amounts for a new Raydium AMM liquidity position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + "schemas": { + "AmmPoolInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Raydium AMM pool address" - }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "baseTokenAmount", - "required": true, - "description": "Amount of base token to add" + "baseTokenAddress": { + "type": "string" }, - { - "schema": { - "type": "number" - }, - "example": 2, - "in": "query", - "name": "quoteTokenAmount", - "required": true, - "description": "Amount of quote token to add" + "quoteTokenAddress": { + "type": "string" }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "example": 2, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" + "feePct": { + "format": "decimal", + "type": "string" + }, + "price": { + "format": "decimal", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "string" } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - } - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] - } - } - } + }, + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmAddLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" }, - "500": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AmmAddLiquidityResponseData" } - } - } - }, - "/connectors/raydium/amm/quote-swap": { - "get": { - "tags": ["/connector/raydium"], - "description": "Get swap quote for Raydium AMM", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + }, + "required": [ + "signature", + "status" + ] + }, + "AmmAddLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position the liquidity went into. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "positionRent": { + "format": "decimal", + "description": "Native token locked as rent when this call opened the position. Absent when adding to a position that already existed, and on fungible-LP AMMs.", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "string" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "AmmQuoteLiquidityResponse": { + "type": "object", + "properties": { + "poolAddress": { + "description": "Pool the quote was computed against", + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "in": "query", - "name": "poolAddress", - "required": false, - "description": "AMM pool address (optional - can be looked up from baseToken and quoteToken)" + "baseLimited": { + "type": "boolean" }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "baseToken", - "required": true, - "description": "Token to determine swap direction" + "baseTokenAmount": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false, - "description": "The other token in the pair (optional - required if poolAddress not provided)" + "quoteTokenAmount": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "amount", - "required": true, - "description": "Amount to swap" + "baseTokenAmountMax": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "in": "query", - "name": "side", - "required": true, - "description": "Trade direction" + "quoteTokenAmountMax": { + "format": "decimal", + "type": "string" + } + }, + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] + }, + "AmmRemoveLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "example": 2, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AmmRemoveLiquidityResponseData" } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" - ] - } - } - } + }, + "required": [ + "signature", + "status" + ] + }, + "AmmRemoveLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "positionRentRefunded": { + "format": "decimal", + "description": "Native token rent returned when the position account closed. Present only on a 100% removal from an AMM whose positions are accounts.", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "string" } - } - } - }, - "/connectors/raydium/amm/execute-swap": { - "post": { - "tags": ["/connector/raydium"], - "description": "Execute a swap on Raydium AMM or CPMM", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "network": { - "type": "string", - "default": "mainnet-beta" - }, - "poolAddress": { - "type": "string", - "example": "" - }, - "baseToken": { - "type": "string", - "example": "SOL" - }, - "quoteToken": { - "type": "string", - "example": "USDC" - }, - "amount": { - "type": "number", - "example": 0.01 - }, - "side": { - "type": "string", - "example": "SELL" - }, - "slippagePct": { - "type": "number", - "example": 1 - } - }, - "required": ["baseToken", "amount", "side"] - } - } + }, + "required": [ + "fee", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + }, + "AmmCreatePoolResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" }, - "required": true + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Initial price the pool was seeded at (quote per base)", + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AmmCreatePoolResponseData" + } }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "baseTokenBalanceChange": { - "type": "number" - }, - "quoteTokenBalanceChange": { - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "signature", + "status", + "poolAddress" + ] + }, + "AmmCreatePoolResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "string" } - } - } - }, - "/connectors/raydium/amm/add-liquidity": { - "post": { - "tags": ["/connector/raydium"], - "description": "Add liquidity to a Raydium AMM/CPMM pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "poolAddress": { - "description": "Raydium AMM pool address", - "type": "string", - "example": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" - }, - "baseTokenAmount": { - "description": "Amount of base token to add", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to add", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["poolAddress", "baseTokenAmount", "quoteTokenAmount"] - } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "PositionDetail": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "format": "decimal", + "description": "Liquidity held by this position (LP units)", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "string" + } + }, + "required": [ + "positionAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmPositionInfo": { + "type": "object", + "properties": { + "poolAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "lpTokenAmount": { + "format": "decimal", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "string" + }, + "price": { + "format": "decimal", + "type": "string" + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionDetail" } + } + }, + "required": [ + "poolAddress", + "walletAddress", + "baseTokenAddress", + "quoteTokenAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount", + "price" + ] + }, + "EstimateGasRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" + } + } + }, + "EstimateGasResponse": { + "type": "object", + "properties": { + "feePerComputeUnit": { + "format": "decimal", + "type": "string" }, - "required": true + "denomination": { + "type": "string" + }, + "computeUnits": { + "type": "number" + }, + "feeAsset": { + "type": "string" + }, + "fee": { + "format": "decimal", + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "gasType": { + "type": "string" + }, + "maxFeePerGas": { + "format": "decimal", + "type": "string" + }, + "maxPriorityFeePerGas": { + "format": "decimal", + "type": "string" + }, + "priorityFeeLevel": { + "type": "string" + }, + "priorityFeePerCUEstimate": { + "format": "decimal", + "type": "string" + } }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "feePerComputeUnit", + "denomination", + "computeUnits", + "feeAsset", + "fee", + "timestamp" + ] + }, + "BalanceRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" + }, + "address": { + "type": "string" + }, + "tokens": { + "description": "a list of token symbols or addresses", + "type": "array", + "items": { + "type": "string" + } + }, + "fetchAll": { + "description": "fetch all tokens in wallet, not just those in token list (default: false)", + "type": "boolean" } } - } - }, - "/connectors/raydium/amm/remove-liquidity": { - "post": { - "tags": ["/connector/raydium"], - "description": "Remove liquidity from a Raydium AMM/CPMM pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "poolAddress": { - "description": "Raydium AMM pool address", - "type": "string", - "example": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" - }, - "percentageToRemove": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "type": "number", - "example": 100 - } - }, - "required": ["poolAddress", "percentageToRemove"] - } + }, + "BalanceResponse": { + "type": "object", + "properties": { + "balances": { + "type": "object", + "additionalProperties": { + "type": "number" } - }, - "required": true + } }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] - } + "required": [ + "balances" + ] + }, + "PollRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" + }, + "signature": { + "description": "Transaction signature/hash", + "type": "string" + } + }, + "required": [ + "signature" + ] + }, + "PollResponse": { + "type": "object", + "properties": { + "currentBlock": { + "type": "number" + }, + "signature": { + "type": "string" + }, + "txBlock": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" } - } + ] + }, + "txStatus": { + "description": "Transaction status: 1 = confirmed, 0 = pending, -1 = failed, -2 = not found (unknown to the chain: never received or dropped; on Solana this is terminal once the transaction blockhash expires)", + "type": "number" + }, + "fee": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "description": "Error info if failed: \"TYPE (code): message\"", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "txData": { + "anyOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "currentBlock", + "signature", + "txBlock", + "txStatus", + "fee", + "error", + "txData" + ] + }, + "StatusRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" } } - } - }, - "/connectors/raydium/clmm/pool-info": { - "get": { - "tags": ["/connector/raydium"], - "description": "Get CLMM pool information from Raydium", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + }, + "StatusResponse": { + "type": "object", + "properties": { + "chain": { + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Raydium CLMM pool address" + "network": { + "type": "string" + }, + "rpcUrl": { + "type": "string" + }, + "rpcProvider": { + "type": "string" + }, + "currentBlockNumber": { + "type": "number" + }, + "nativeCurrency": { + "type": "string" + }, + "swapProvider": { + "type": "string" } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ] - } - } - } + }, + "required": [ + "chain", + "network", + "rpcUrl", + "rpcProvider", + "currentBlockNumber", + "nativeCurrency", + "swapProvider" + ] + }, + "ChainQuoteSwapResponse": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "string" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "string" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "string" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "string" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "string" + }, + "poolAddress": { + "description": "Pool address for AMM/CLMM swaps", + "type": "string" + }, + "routePath": { + "description": "Route path for router-based swaps", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage", + "type": "string" } - } - } - }, - "/connectors/raydium/clmm/positions-owned": { - "get": { - "tags": ["/connector/raydium"], - "description": "Retrieve all positions owned by a user's wallet across all Raydium CLMM pools", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn" + ] + }, + "ChainExecuteSwapResponse": { + "type": "object", + "properties": { + "signature": { + "description": "Transaction signature/hash", + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "in": "query", - "name": "walletAddress", - "required": true, - "description": "Solana wallet address to check for positions" + "status": { + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ChainExecuteSwapResponseData" } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } - } + }, + "required": [ + "signature", + "status" + ] + }, + "ChainExecuteSwapResponseData": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Actual amount of tokenIn swapped", + "type": "string" + }, + "amountOut": { + "format": "decimal", + "description": "Actual amount of tokenOut received", + "type": "string" + }, + "fee": { + "format": "decimal", + "description": "Transaction fee paid", + "type": "string" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "description": "Change in base token balance (negative for decrease)", + "type": "string" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "description": "Change in quote token balance (negative for decrease)", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "string" + }, + "poolAddress": { + "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", + "type": "string" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + }, + "WrapRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" + }, + "address": { + "description": "Wallet address holding the native token", + "type": "string" + }, + "amount": { + "description": "Amount of the native token to wrap, in whole units (not lamports/wei)", + "type": "string", + "example": "1.0" + } + }, + "required": [ + "address", + "amount" + ] + }, + "UnwrapRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" + }, + "address": { + "description": "Wallet address holding the wrapped token", + "type": "string" + }, + "amount": { + "description": "Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.", + "type": "string", + "example": "1.0" + } + }, + "required": [ + "address" + ] + }, + "ChainWrapResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ChainWrapResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ChainWrapResponseData": { + "type": "object", + "properties": { + "nonce": { + "description": "EVM transaction nonce; absent on non-EVM chains", + "type": "number" + }, + "fee": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": [ + "fee", + "amount", + "wrappedAddress", + "nativeToken", + "wrappedToken" + ] + }, + "RouterQuoteSwapResponse": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "string" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "string" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "string" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "string" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "string" + }, + "poolAddress": { + "description": "Pool address for AMM/CLMM swaps", + "type": "string" + }, + "routePath": { + "description": "Route path for router-based swaps", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage", + "type": "string" + }, + "quoteId": { + "description": "Identifier to pass to /trading/router/execute-quote", + "type": "string" + }, + "approximation": { + "description": "True when a BUY was approximated via a sell-leg ExactIn quote because the router has no ExactOut route; amountOut is an estimate rather than exact", + "type": "boolean" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn", + "quoteId" + ] + }, + "PoolListItem": { + "type": "object", + "properties": { + "address": { + "description": "Pool address", + "type": "string" + }, + "name": { + "description": "Pool name (e.g., SOL-USDC)", + "type": "string" + }, + "baseTokenAddress": { + "description": "Base token address", + "type": "string" + }, + "baseTokenSymbol": { + "description": "Base token symbol", + "type": "string" + }, + "quoteTokenAddress": { + "description": "Quote token address", + "type": "string" + }, + "quoteTokenSymbol": { + "description": "Quote token symbol", + "type": "string" + }, + "binStep": { + "description": "Bin step / tick spacing", + "type": "number" + }, + "baseFee": { + "format": "decimal", + "description": "Base fee percentage", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Current price", + "type": "string" + }, + "tvl": { + "format": "decimal", + "description": "Total value locked in USD", + "type": "string" + }, + "apr": { + "format": "decimal", + "description": "Annual percentage rate", + "type": "string" + }, + "apy": { + "format": "decimal", + "description": "Annual percentage yield", + "type": "string" + }, + "volume24h": { + "format": "decimal", + "description": "24-hour trading volume", + "type": "string" + }, + "fees24h": { + "format": "decimal", + "description": "24-hour fees collected", + "type": "string" + } + }, + "required": [ + "address", + "name", + "baseTokenAddress", + "baseTokenSymbol", + "quoteTokenAddress", + "quoteTokenSymbol", + "binStep", + "baseFee", + "price", + "tvl" + ] + }, + "ClmmFetchPoolsResponse": { + "type": "object", + "properties": { + "pools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PoolListItem" } + }, + "total": { + "description": "Total number of matching pools", + "type": "number" + }, + "page": { + "description": "Current page number", + "type": "number" + }, + "pageSize": { + "description": "Number of pools per page", + "type": "number" } - } - } - }, - "/connectors/raydium/clmm/position-info": { - "get": { - "tags": ["/connector/raydium"], - "description": "Get info about a Raydium CLMM position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + }, + "required": [ + "pools", + "total", + "page", + "pageSize" + ] + }, + "BinLiquidity": { + "type": "object", + "properties": { + "binId": { + "type": "number" }, - { - "schema": { - "type": "string" - }, - "example": "\u003Csample-position-address\u003E", - "in": "query", - "name": "positionAddress", - "required": true, - "description": "Position NFT address" + "price": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "in": "query", - "name": "walletAddress", - "required": false, - "description": "Solana wallet address" + "baseTokenAmount": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "string" } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } + }, + "required": [ + "binId", + "price", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "ClmmPoolInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "binStep": { + "type": "number" + }, + "feePct": { + "format": "decimal", + "type": "string" + }, + "price": { + "format": "decimal", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "string" + }, + "activeBinId": { + "type": "number" + }, + "bins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinLiquidity" } } - } - } - }, - "/connectors/raydium/clmm/quote-position": { - "get": { - "tags": ["/connector/raydium"], - "description": "Quote amounts for a new Raydium CLMM position", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + }, + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount", + "activeBinId" + ] + }, + "ClmmPositionInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" }, - { - "schema": { - "type": "number" - }, - "example": 100, - "in": "query", - "name": "lowerPrice", - "required": true, - "description": "Lower price bound for the position" + "poolAddress": { + "type": "string" }, - { - "schema": { - "type": "number" - }, - "example": 300, - "in": "query", - "name": "upperPrice", - "required": true, - "description": "Upper price bound for the position" + "baseTokenAddress": { + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Raydium CLMM pool address" + "quoteTokenAddress": { + "type": "string" }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "baseTokenAmount", - "required": false, - "description": "Amount of base token to deposit" + "baseTokenAmount": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "type": "number" - }, - "example": 2, - "in": "query", - "name": "quoteTokenAmount", - "required": false, - "description": "Amount of quote token to deposit" + "quoteTokenAmount": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "example": 2, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" + "baseFeeAmount": { + "format": "decimal", + "type": "string" + }, + "quoteFeeAmount": { + "format": "decimal", + "type": "string" + }, + "lowerBinId": { + "type": "number" + }, + "upperBinId": { + "type": "number" + }, + "lowerPrice": { + "format": "decimal", + "type": "string" + }, + "upperPrice": { + "format": "decimal", + "type": "string" + }, + "price": { + "format": "decimal", + "type": "string" } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] - } - } - } + }, + "required": [ + "address", + "poolAddress", + "baseTokenAddress", + "quoteTokenAddress", + "baseTokenAmount", + "quoteTokenAmount", + "baseFeeAmount", + "quoteFeeAmount", + "lowerBinId", + "upperBinId", + "lowerPrice", + "upperPrice", + "price" + ] + }, + "ClmmOpenPositionResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClmmOpenPositionResponseData" } - } - } - }, - "/connectors/raydium/clmm/quote-swap": { - "get": { - "tags": ["/connector/raydium"], - "description": "Get swap quote for Raydium CLMM", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + }, + "required": [ + "signature", + "status" + ] + }, + "ClmmOpenPositionResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "in": "query", - "name": "poolAddress", - "required": false, - "description": "CLMM pool address (optional - can be looked up from tokens)" + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "type": "string" + }, + "positionRent": { + "format": "decimal", + "type": "string" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "string" + } + }, + "required": [ + "fee", + "positionAddress", + "positionRent", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "ClmmAddLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClmmAddLiquidityResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ClmmAddLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "string" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "ClmmRemoveLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClmmRemoveLiquidityResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ClmmRemoveLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "string" + } + }, + "required": [ + "fee", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + }, + "ClmmCollectFeesResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClmmCollectFeesResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ClmmCollectFeesResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "baseToken", - "required": true, - "description": "Token to determine swap direction" + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false, - "description": "The other token in the pair" + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "amount", - "required": true, - "description": "Amount to swap" + "baseFeeAmountCollected": { + "format": "decimal", + "type": "string" }, - { - "schema": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "in": "query", - "name": "side", - "required": true, - "description": "Trade direction" + "quoteFeeAmountCollected": { + "format": "decimal", + "type": "string" + } + }, + "required": [ + "fee", + "baseFeeAmountCollected", + "quoteFeeAmountCollected" + ] + }, + "ClmmClosePositionResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "example": 2, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClmmClosePositionResponseData" } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" - ] - } - } - } + }, + "required": [ + "signature", + "status" + ] + }, + "ClmmClosePositionResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" + }, + "positionRentRefunded": { + "format": "decimal", + "type": "string" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "string" + }, + "baseFeeAmountCollected": { + "format": "decimal", + "type": "string" + }, + "quoteFeeAmountCollected": { + "format": "decimal", + "type": "string" } - } - } - }, - "/connectors/raydium/clmm/execute-swap": { - "post": { - "tags": ["/connector/raydium"], - "description": "Execute a swap on Raydium CLMM", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "poolAddress": { - "description": "CLMM pool address (optional)", - "type": "string", - "example": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv" - }, - "baseToken": { - "description": "Base token symbol or address", - "type": "string", - "example": "SOL" - }, - "quoteToken": { - "description": "Quote token symbol or address", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount to swap", - "type": "number", - "example": 0.01 - }, - "side": { - "description": "Trade direction", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string", - "example": "SELL" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["baseToken", "amount", "side"] - } - } + }, + "required": [ + "fee", + "positionRentRefunded", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved", + "baseFeeAmountCollected", + "quoteFeeAmountCollected" + ] + }, + "ClmmCreatePoolResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" }, - "required": true + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Initial price the pool was initialized at (quote per base)", + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ClmmCreatePoolResponseData" + } }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "baseTokenBalanceChange": { - "type": "number" - }, - "quoteTokenBalanceChange": { - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "signature", + "status", + "poolAddress" + ] + }, + "ClmmCreatePoolResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "string" } - } - } - }, - "/connectors/raydium/clmm/open-position": { - "post": { - "tags": ["/connector/raydium"], - "description": "Open a new Raydium CLMM position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "lowerPrice": { - "description": "Lower price bound for the position", - "type": "number", - "example": 100 - }, - "upperPrice": { - "description": "Upper price bound for the position", - "type": "number", - "example": 300 - }, - "poolAddress": { - "description": "Raydium CLMM pool address", - "type": "string", - "example": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv" - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["lowerPrice", "upperPrice", "poolAddress"] - } - } + }, + "required": [ + "fee" + ] + }, + "ClmmQuoteLiquidityResponse": { + "type": "object", + "properties": { + "poolAddress": { + "description": "Pool the quote was computed against", + "type": "string" }, - "required": true + "baseLimited": { + "type": "boolean" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "string" + }, + "baseTokenAmountMax": { + "format": "decimal", + "type": "string" + }, + "quoteTokenAmountMax": { + "format": "decimal", + "type": "string" + }, + "liquidity": {} + }, + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] + }, + "AmmCreatePoolRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address (pool creator + payer)", + "default": "", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to seed the pool with", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", + "type": "number" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + }, + "configAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Meteora DAMM v2 config account address (required for the meteora connector — configs are permissionless accounts with no index derivation, so the address must be explicit).", + "type": "string" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium" + ], + "description": "Raydium CPMM fee-config index (optional; defaults to the first available config).", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "baseTokenAmount" + ] + }, + "AmmAddRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to add", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to add", + "type": "number" + }, + "positionAddress": { + "x-connectors": [ + "meteora" + ], + "description": "meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new position. Ignored by fungible-LP AMMs.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmRemoveRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "positionAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.", + "type": "string" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "percentageToRemove" + ] + }, + "AmmPoolInfoRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + } + }, + "required": [ + "connector", + "chainNetwork", + "poolAddress" + ] + }, + "AmmPositionInfoRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + } + }, + "required": [ + "connector", + "chainNetwork", + "poolAddress", + "walletAddress" + ] + }, + "AmmPositionsOwnedRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "AMM connector (only non-fungible-LP AMMs supported: meteora)", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address to list positions for", + "default": "", + "type": "string" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress" + ] + }, + "AmmQuoteLiquidityRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "ClmmOpenRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + }, + "lowerPrice": { + "format": "decimal", + "description": "Lower price bound for the position", + "type": "number", + "example": 150 + }, + "upperPrice": { + "format": "decimal", + "description": "Upper price bound for the position", + "type": "number", + "example": 250 + }, + "poolAddress": { + "description": "Pool address", + "type": "string", + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "strategyType": { + "x-connectors": [ + "meteora" + ], + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "lowerPrice", + "upperPrice", + "poolAddress" + ] + }, + "ClmmAddRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit (omit for single-sided quote deposit)", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit (omit for single-sided base deposit)", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "strategyType": { + "x-connectors": [ + "meteora" + ], + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + }, + "ClmmRemoveRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Honored by orca, uniswap and pancakeswap; the other connectors remove at their configured slippagePct. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress", + "percentageToRemove" + ] + }, + "ClmmCollectFeesRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + }, + "ClmmCloseRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage for the withdrawal. Enforced by orca, uniswap and pancakeswap; meteora, raydium and pancakeswap-sol close with no minimum-amount check at all, so it changes nothing there. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + }, + "ClmmCreatePoolRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address (pool creator + payer)", + "default": "", + "type": "string" + }, + "baseToken": { + "type": "string" + }, + "quoteToken": { + "type": "string" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + }, + "binStep": { + "x-connectors": [ + "meteora", + "orca" + ], + "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", + "type": "number" + }, + "feeBps": { + "x-connectors": [ + "meteora", + "uniswap", + "pancakeswap" + ], + "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", + "type": "number" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium", + "pancakeswap-sol" + ], + "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", + "type": "number" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken" + ] + }, + "ClmmFetchPoolsRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "CLMM connector whose pool-discovery API to query", + "enum": [ + "meteora", + "orca" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "limit": { + "minimum": 1, + "maximum": 1000, + "default": 50, + "description": "Maximum number of pools to return", + "type": "number" + }, + "query": { + "description": "Search pools by name, token, or address", + "type": "string", + "example": "SOL" + }, + "sortBy": { + "description": "Sort field. Meteora takes a \"field:direction\" pair; Orca takes the field alone with sortDirection.", + "type": "string", + "example": "tvl" + }, + "page": { + "minimum": 0, + "description": "0-based page index. Only connectors whose API paginates honor this.", + "x-connectors": [ + "meteora" + ], + "type": "number" + }, + "includeUnverified": { + "description": "Include unverified pools", + "x-connectors": [ + "meteora" + ], + "type": "boolean" + }, + "sortDirection": { + "description": "Sort direction", + "enum": [ + "asc", + "desc" + ], + "x-connectors": [ + "orca" + ], + "type": "string" + }, + "verifiedOnly": { + "description": "Return only verified pools", + "x-connectors": [ + "orca" + ], + "type": "boolean" + } + }, + "required": [ + "chainNetwork", + "connector" + ] + }, + "ClmmPoolInfoRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string", + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "binCount": { + "description": "If > 0, include a `bins` array of per-tick liquidity around the active tick. Supported by every connector except Meteora, which always returns its bins and ignores this. Default 0 = skip the bin fetch.", + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" + } + }, + "required": [ + "connector", + "chainNetwork", + "poolAddress" + ] + }, + "ClmmPositionInfoRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "positionAddress": { + "description": "Position address or NFT token ID", + "type": "string", + "example": "" + } + }, + "required": [ + "connector", + "chainNetwork", + "positionAddress" + ] + }, + "ClmmPositionsOwnedRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "", + "type": "string" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress" + ] + }, + "ClmmQuoteLiquidityRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "lowerPrice": { + "format": "decimal", + "description": "Lower price bound for the position", + "type": "number", + "example": 150 + }, + "upperPrice": { + "format": "decimal", + "description": "Upper price bound for the position", + "type": "number", + "example": 250 + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string", + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "lowerPrice", + "upperPrice", + "poolAddress" + ] + }, + "RouterExecuteQuoteRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "type": "string", + "example": "jupiter" + }, + "walletAddress": { + "description": "Wallet address that will execute the quote", + "default": "", + "type": "string" + }, + "quoteId": { + "description": "ID of a quote returned by /trading/router/quote-swap", + "type": "string" + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "quoteId" + ] + }, + "RouterExecuteSwapRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "type": "string", + "example": "jupiter" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.", + "default": true, + "x-connectors": [ + "jupiter", + "dflow", + "okx", + "titan" + ], + "type": "boolean" + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "RouterQuoteSwapRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "type": "string", + "example": "jupiter" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 1, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "walletAddress": { + "description": "Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata.", + "type": "string" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing.", + "default": true, + "x-connectors": [ + "jupiter", + "dflow", + "okx", + "titan" + ], + "type": "boolean" + }, + "indicativePrice": { + "description": "Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote.", + "x-connectors": [ + "0x" + ], + "type": "boolean" + } }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "chainNetwork", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "AmmQuoteSwapRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "AMM connector to price the swap against", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 1, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } - } - } - }, - "/connectors/raydium/clmm/add-liquidity": { - "post": { - "tags": ["/connector/raydium"], - "description": "Add liquidity to existing Raydium CLMM position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - }, - "baseTokenAmount": { - "description": "Amount of base token to add", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to add", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["positionAddress", "baseTokenAmount", "quoteTokenAmount"] - } - } - }, - "required": true }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "chainNetwork", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "ClmmQuoteSwapRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "CLMM connector to price the swap against", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 1, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } - } - } - }, - "/connectors/raydium/clmm/remove-liquidity": { - "post": { - "tags": ["/connector/raydium"], - "description": "Remove liquidity from Raydium CLMM position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position NFT address to remove liquidity from", - "type": "string", - "example": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" - }, - "percentageToRemove": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "type": "number", - "example": 100 - } - }, - "required": ["positionAddress", "percentageToRemove"] - } - } - }, - "required": true }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "chainNetwork", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "AmmExecuteSwapRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "AMM connector to execute the swap against", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } - } - } - }, - "/connectors/raydium/clmm/collect-fees": { - "post": { - "tags": ["/connector/raydium"], - "description": "Collect fees from a Raydium CLMM position by removing 1% of liquidity", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "type": "string", - "default": "mainnet-beta" - }, - "walletAddress": { - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "type": "string" - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "ClmmExecuteSwapRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "CLMM connector to execute the swap against", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } - } - } - }, - "/connectors/raydium/clmm/close-position": { - "post": { - "tags": ["/connector/raydium"], - "description": "Close a Raydium CLMM position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position NFT address to close", - "type": "string", - "example": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": ["signature", "status"] - } - } - } + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "AllowancesRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "The Ethereum network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "address": { + "description": "Ethereum wallet address", + "default": "", + "type": "string" + }, + "spender": { + "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address", + "type": "string", + "example": "uniswap/router" + }, + "tokens": { + "description": "Array of token symbols or addresses", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "USDC", + "WETH" + ] } - } - } - }, - "/connectors/uniswap/router/quote-swap": { - "get": { - "tags": ["/connector/uniswap"], - "description": "Get an executable swap quote from Uniswap Universal Router", - "parameters": [ - { - "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The EVM network to use" + }, + "required": [ + "spender", + "tokens" + ] + }, + "AllowancesResponse": { + "type": "object", + "properties": { + "spender": { + "type": "string" }, - { - "schema": { + "approvals": { + "type": "object", + "additionalProperties": { "type": "string" - }, - "example": "WETH", - "in": "query", - "name": "baseToken", - "required": true, - "description": "First token in the trading pair" + } + } + }, + "required": [ + "spender", + "approvals" + ] + }, + "ApproveRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "network": { + "description": "The Ethereum network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "address": { + "description": "Ethereum wallet address", + "default": "", + "type": "string" + }, + "spender": { + "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address", + "type": "string", + "example": "uniswap/router" + }, + "token": { + "description": "Token symbol or address", + "type": "string", + "example": "USDC" + }, + "amount": { + "description": "The amount to approve. If not provided, defaults to maximum amount (unlimited approval).", + "default": "", + "type": "string" + } + }, + "required": [ + "spender", + "token" + ] + }, + "ApproveResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": true, - "description": "Second token in the trading pair" + "status": { + "description": "TransactionStatus enum value", + "type": "number" }, - { - "schema": { - "type": "number" - }, - "example": 0.001, - "in": "query", - "name": "amount", - "required": true, - "description": "Amount of base token to trade" + "data": { + "$ref": "#/components/schemas/ApproveResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ApproveResponseData": { + "type": "object", + "properties": { + "tokenAddress": { + "type": "string" }, - { - "schema": { - "enum": ["BUY", "SELL"], - "type": "string" - }, - "in": "query", - "name": "side", - "required": true, - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" + "spender": { + "type": "string" }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" + "amount": { + "type": "string" + }, + "nonce": { + "type": "number" }, + "fee": { + "type": "string" + } + }, + "required": [ + "tokenAddress", + "spender", + "amount", + "nonce", + "fee" + ] + }, + "RemoveWalletRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "chain": { + "description": "Blockchain to remove wallet from", + "enum": [ + "ethereum", + "solana" + ], + "type": "string", + "example": "solana" + }, + "address": { + "description": "Wallet address to remove", + "type": "string" + } + }, + "required": [ + "chain", + "address" + ] + }, + "AddHardwareWalletRequest": { + "additionalProperties": false, + "type": "object", + "properties": { + "chain": { + "description": "Blockchain for hardware wallet", + "enum": [ + "ethereum", + "solana" + ], + "default": "solana", + "type": "string", + "example": "solana" + }, + "address": { + "description": "Hardware wallet address to add (must exist on connected Ledger device)", + "type": "string" + }, + "setDefault": { + "description": "Set this wallet as the default for the chain", + "default": false, + "type": "boolean" + } + }, + "required": [ + "chain", + "address" + ] + }, + "Token": { + "type": "object", + "properties": { + "chainId": { + "description": "The chain ID", + "type": "number", + "example": 1 + }, + "name": { + "description": "The full name of the token", + "type": "string", + "example": "USD Coin" + }, + "symbol": { + "description": "The token symbol", + "type": "string", + "example": "USDC" + }, + "address": { + "description": "The token contract address", + "type": "string", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "decimals": { + "description": "The number of decimals the token uses", + "minimum": 0, + "maximum": 255, + "type": "number", + "example": 6 + } + }, + "required": [ + "name", + "symbol", + "address", + "decimals" + ] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "statusCode": { + "description": "HTTP status code", + "type": "integer", + "example": 400 + }, + "error": { + "description": "HTTP status name", + "type": "string", + "example": "Bad Request" + }, + "message": { + "description": "What went wrong, in terms of the request that caused it", + "type": "string", + "example": "Connector 'meteora' runs on solana, not ethereum" + }, + "code": { + "description": "Machine-readable cause, present when Gateway can name one. This is what a caller branches on: TRANSACTION_TIMEOUT and RATE_LIMITED are retryable, the rest are not.", + "enum": [ + "TRANSACTION_TIMEOUT", + "SIMULATION_FAILED", + "TRANSACTION_FAILED", + "INSUFFICIENT_BALANCE", + "INVALID_PARAMS", + "SLIPPAGE_EXCEEDED", + "NO_ROUTE_FOUND", + "RATE_LIMITED" + ], + "type": "string" + } + }, + "required": [ + "statusCode", + "error", + "message" + ] + } + } + }, + "paths": { + "/config/": { + "get": { + "operationId": "getConfig", + "tags": [ + "/config" + ], + "description": "Get configuration settings. Returns all configurations if no parameters are specified. Use namespace to get a specific config (e.g., server, ethereum-mainnet, solana-mainnet-beta, uniswap).", + "parameters": [ { "schema": { - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", "type": "string" }, + "examples": { + "server": { + "value": "server" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "uniswap": { + "value": "uniswap" + } + }, "in": "query", - "name": "walletAddress", + "name": "namespace", "required": false, - "description": "Wallet address for more accurate quotes (optional)" + "description": "Optional configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")" } ], "responses": { @@ -9787,160 +3806,27 @@ "application/json": { "schema": { "type": "object", - "properties": { - "quoteId": { - "description": "Unique identifier for this quote", - "type": "string" - }, - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "routePath": { - "description": "Human-readable route path", - "type": "string" - } - }, - "required": [ - "quoteId", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn" - ] + "additionalProperties": true } } } - } - } - } - }, - "/connectors/uniswap/router/execute-quote": { - "post": { - "tags": ["/connector/uniswap"], - "description": "Execute a previously fetched quote from Uniswap Universal Router", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "quoteId": { - "description": "ID of the quote to execute", - "type": "string", - "example": "123e4567-e89b-12d3-a456-426614174000" - } - }, - "required": ["quoteId"] + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -9948,58 +3834,94 @@ } } }, - "/connectors/uniswap/router/execute-swap": { + "/config/update": { "post": { - "tags": ["/connector/uniswap"], - "description": "Quote and execute a token swap on Uniswap Universal Router in one step", + "operationId": "updateConfig", + "tags": [ + "/config" + ], + "description": "Update a specific configuration value", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "baseToken": { - "description": "Token to determine swap direction", + "namespace": { + "description": "Configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")", "type": "string", - "example": "WETH" + "example": "server" }, - "quoteToken": { - "description": "The other token in the pair", + "path": { + "description": "Configuration path within the namespace (e.g., \"nodeURL\", \"manualGasPrice\")", "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount of base token to trade", - "type": "number", - "example": 0.001 - }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": ["BUY", "SELL"], - "type": "string" + "example": "nodeURL" }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 1 + "value": { + "description": "Configuration value", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "properties": {} + }, + { + "type": "array", + "items": {} + } + ] + } + }, + "required": [ + "namespace", + "path", + "value" + ] + }, + "examples": { + "example1": { + "value": { + "namespace": "solana-mainnet-beta", + "path": "maxFee", + "value": 0.01 + } + }, + "example2": { + "value": { + "namespace": "ethereum-mainnet", + "path": "nodeURL", + "value": "https://eth-mainnet.g.alchemy.com/v2/your-api-key" + } + }, + "example3": { + "value": { + "namespace": "ethereum-mainnet", + "path": "gasLimitTransaction", + "value": 3000000 + } + }, + "example4": { + "value": { + "namespace": "solana-devnet", + "path": "retryCount", + "value": 5 } }, - "required": ["baseToken", "quoteToken", "amount", "side"] + "example5": { + "value": { + "namespace": "server", + "path": "port", + "value": 15888 + } + } } } }, @@ -10013,58 +3935,34 @@ "schema": { "type": "object", "properties": { - "signature": { - "description": "Transaction signature/hash", + "message": { + "description": "Status message", "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] } }, - "required": ["signature", "status"] + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10072,33 +3970,13 @@ } } }, - "/connectors/uniswap/amm/pool-info": { + "/config/chains": { "get": { - "tags": ["/connector/uniswap"], - "description": "Get AMM pool information from Uniswap V2", - "parameters": [ - { - "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The EVM network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Uniswap V2 pool address" - } + "operationId": "listChains", + "tags": [ + "/config" ], + "description": "Returns a list of available blockchain networks supported by Gateway.", "responses": { "200": { "description": "Default Response", @@ -10107,95 +3985,65 @@ "schema": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" + "chains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "chain": { + "type": "string" + }, + "networks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "chain", + "networks" + ] + } } }, "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount" + "chains" ] } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/connectors/uniswap/amm/position-info": { + "/config/connectors": { "get": { - "tags": ["/connector/uniswap"], - "description": "Get position information for a Uniswap V2 pool", - "parameters": [ - { - "schema": { - "type": "string", - "default": "base" - }, - "in": "query", - "name": "network", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "in": "query", - "name": "walletAddress", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "", - "in": "query", - "name": "poolAddress", - "required": true - }, - { - "schema": { - "type": "string" - }, - "example": "WETH", - "in": "query", - "name": "baseToken", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false - } + "operationId": "listConnectors", + "tags": [ + "/config" ], + "description": "Returns a list of available DEX connectors and their supported blockchain networks.", "responses": { "200": { "description": "Default Response", @@ -10204,118 +4052,76 @@ "schema": { "type": "object", "properties": { - "poolAddress": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "lpTokenAmount": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "price": { - "type": "number" + "connectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "trading_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "chain": { + "type": "string" + }, + "networks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "trading_types", + "chain", + "networks" + ] + } } }, "required": [ - "poolAddress", - "walletAddress", - "baseTokenAddress", - "quoteTokenAddress", - "lpTokenAmount", - "baseTokenAmount", - "quoteTokenAmount", - "price" + "connectors" ] } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/connectors/uniswap/amm/quote-swap": { + "/config/namespaces": { "get": { - "tags": ["/connector/uniswap"], - "description": "Get swap quote for Uniswap V2 AMM", - "parameters": [ - { - "schema": { - "type": "string", - "default": "base" - }, - "in": "query", - "name": "network", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "", - "in": "query", - "name": "poolAddress", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "WETH", - "in": "query", - "name": "baseToken", - "required": true - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false - }, - { - "schema": { - "type": "number" - }, - "example": 0.001, - "in": "query", - "name": "amount", - "required": true - }, - { - "schema": { - "type": "string", - "enum": ["BUY", "SELL"] - }, - "example": "SELL", - "in": "query", - "name": "side", - "required": true - }, - { - "schema": { - "type": "number" - }, - "example": 1, - "in": "query", - "name": "slippagePct", - "required": false - } + "operationId": "listNamespaces", + "tags": [ + "/config" ], + "description": "Returns a list of all configuration namespaces available in Gateway.", "responses": { "200": { "description": "Default Response", @@ -10324,121 +4130,58 @@ "schema": { "type": "object", "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" + "namespaces": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" + "namespaces" ] } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/connectors/uniswap/amm/quote-liquidity": { + "/wallet/": { "get": { - "tags": ["/connector/uniswap"], - "description": "Get liquidity quote for a Uniswap V2 pool", + "operationId": "listWallets", + "tags": [ + "/wallet" + ], + "description": "Get all wallets across different chains", "parameters": [ { "schema": { - "type": "string", - "default": "base" - }, - "in": "query", - "name": "network", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "", - "in": "query", - "name": "poolAddress", - "required": true - }, - { - "schema": { - "type": "number" - }, - "example": 0.001, - "in": "query", - "name": "baseTokenAmount", - "required": true - }, - { - "schema": { - "type": "number" - }, - "example": 2.5, - "in": "query", - "name": "quoteTokenAmount", - "required": true - }, - { - "schema": { - "type": "number" - }, - "example": 1, - "in": "query", - "name": "slippagePct", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "WETH", - "in": "query", - "name": "baseToken", - "required": false - }, - { - "schema": { - "type": "string" + "default": true, + "type": "boolean" }, - "example": "USDC", "in": "query", - "name": "quoteToken", + "name": "showHardware", "required": false } ], @@ -10448,31 +4191,57 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" + "type": "array", + "items": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain name", + "type": "string", + "example": "solana" + }, + "walletAddresses": { + "description": "List of regular wallet addresses with private keys", + "type": "array", + "items": { + "description": "Wallet address (Ethereum format: 0x... or Solana format: base58)", + "type": "string" + } + }, + "hardwareWalletAddresses": { + "description": "List of hardware wallet addresses (Ledger)", + "type": "array", + "items": { + "description": "Wallet address (Ethereum format: 0x... or Solana format: base58)", + "type": "string" + } + } }, - "quoteTokenAmountMax": { - "type": "number" - } - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] + "required": [ + "chain", + "walletAddresses" + ] + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10480,61 +4249,48 @@ } } }, - "/connectors/uniswap/amm/execute-swap": { + "/wallet/add": { "post": { - "tags": ["/connector/uniswap"], - "description": "Execute a swap on Uniswap V2 AMM using Router02", + "operationId": "addWallet", + "tags": [ + "/wallet" + ], + "description": "Add a new wallet using a private key", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "network": { - "description": "The EVM network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "poolAddress": { - "description": "Pool address (optional - can be looked up from tokens)", - "default": "", - "type": "string" - }, - "baseToken": { - "description": "Base token symbol or address", + "chain": { + "description": "Blockchain to add wallet to", + "enum": [ + "ethereum", + "solana" + ], "type": "string", - "example": "WETH" + "example": "solana" }, - "quoteToken": { - "description": "Quote token symbol or address", + "privateKey": { + "description": "Private key for the wallet", "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount to swap", - "type": "number", - "example": 0.001 - }, - "side": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" + "example": "" }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number" + "setDefault": { + "description": "Set this wallet as the default for the chain", + "default": false, + "type": "boolean" } }, - "required": ["baseToken", "amount", "side"] + "required": [ + "chain", + "privateKey" + ] + }, + "example": { + "chain": "solana", + "privateKey": "", + "setDefault": true } } }, @@ -10548,58 +4304,34 @@ "schema": { "type": "object", "properties": { - "signature": { - "description": "Transaction signature/hash", + "address": { + "description": "The wallet address that was added", "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] } }, - "required": ["signature", "status"] + "required": [ + "address" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10607,61 +4339,95 @@ } } }, - "/connectors/uniswap/amm/add-liquidity": { + "/wallet/add-hardware": { "post": { - "tags": ["/connector/uniswap"], - "description": "Add liquidity to a Uniswap V2 pool", + "operationId": "addHardwareWallet", + "tags": [ + "/wallet" + ], + "description": "Add a hardware wallet", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "The EVM network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "walletAddress": { - "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "poolAddress": { - "description": "Address of the Uniswap V2 pool", - "type": "string" - }, - "baseTokenAmount": { - "description": "Amount of base token to add", - "type": "number" - }, - "quoteTokenAmount": { - "description": "Amount of quote token to add", - "type": "number" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" + "$ref": "#/components/schemas/AddHardwareWalletRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "description": "The hardware wallet address that was added", + "type": "string" + }, + "publicKey": { + "description": "Public key of the hardware wallet", + "type": "string" + }, + "derivationPath": { + "description": "BIP32/BIP44 derivation path used", + "type": "string" + }, + "message": { + "description": "Success message", + "type": "string" + } }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 - } - }, - "required": ["poolAddress", "baseTokenAmount", "quoteTokenAmount"] + "required": [ + "address", + "publicKey", + "derivationPath", + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } - }, - "required": true + } + } + } + }, + "/wallet/remove": { + "delete": { + "operationId": "removeWallet", + "tags": [ + "/wallet" + ], + "description": "Remove a wallet by its address (automatically detects wallet type)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveWalletRequest" + } + } + } }, "responses": { "200": { @@ -10671,30 +4437,34 @@ "schema": { "type": "object", "properties": { - "signature": { + "message": { + "description": "Success message indicating wallet type removed", "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] } }, - "required": ["signature", "status"] + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10702,48 +4472,51 @@ } } }, - "/connectors/uniswap/amm/remove-liquidity": { + "/wallet/setDefault": { "post": { - "tags": ["/connector/uniswap"], - "description": "Remove liquidity from a Uniswap V2 pool", + "operationId": "setDefaultWallet", + "tags": [ + "/wallet" + ], + "description": "Set a wallet as default for a specific chain", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "network": { - "description": "The EVM network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "walletAddress": { - "description": "Wallet address that will remove liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "poolAddress": { - "description": "Address of the Uniswap V2 pool", - "type": "string" - }, - "percentageToRemove": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "type": "number" + "chain": { + "description": "Blockchain to set default wallet for", + "enum": [ + "ethereum", + "solana" + ], + "type": "string", + "example": "solana" }, - "gasPrice": { - "description": "Gas price in wei for the transaction", + "address": { + "description": "Wallet address to set as default", "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 } }, - "required": ["poolAddress", "percentageToRemove"] + "required": [ + "chain", + "address" + ] + }, + "examples": { + "example1": { + "value": { + "chain": "ethereum", + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f2BDf8" + } + }, + "example2": { + "value": { + "chain": "solana", + "address": "7UX2i7SucgLMQcfZ75s3VXmZZY4YRUyJN9X1RgfMoDUi" + } + } } } }, @@ -10757,30 +4530,44 @@ "schema": { "type": "object", "properties": { - "signature": { + "message": { + "description": "Success message", "type": "string" }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" + "chain": { + "description": "Chain name", + "type": "string" }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] + "address": { + "description": "Default wallet address", + "type": "string" } }, - "required": ["signature", "status"] + "required": [ + "message", + "chain", + "address" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10788,31 +4575,48 @@ } } }, - "/connectors/uniswap/clmm/pool-info": { + "/tokens/{symbolOrAddress}": { "get": { - "tags": ["/connector/uniswap"], - "description": "Get CLMM pool information from Uniswap V3", + "operationId": "getToken", + "tags": [ + "/tokens" + ], + "description": "Get a specific token by symbol or address", "parameters": [ { "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", - "required": false, - "description": "The EVM network to use" + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "0xd0b53d9277642d899df5c87a3966a349a798f224", - "in": "query", - "name": "poolAddress", + "in": "path", + "name": "symbolOrAddress", "required": true, - "description": "Uniswap V3 pool address" + "description": "Token symbol or address" } ], "responses": { @@ -10823,74 +4627,91 @@ "schema": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" + "token": { + "$ref": "#/components/schemas/Token" }, - "quoteTokenAddress": { + "chainNetwork": { "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" } }, "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" + "token", + "chainNetwork" ] } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/connectors/uniswap/clmm/position-info": { + "/tokens/find/{address}": { "get": { - "tags": ["/connector/uniswap"], - "description": "Get position information for a Uniswap V3 position", + "operationId": "findToken", + "tags": [ + "/tokens" + ], + "description": "Get token information with market data from GeckoTerminal by address", "parameters": [ { "schema": { - "type": "string", - "default": "base" + "type": "string" + }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } }, "in": "query", - "name": "network", - "required": false + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "1234", - "in": "query", - "name": "positionAddress", + "examples": { + "So11111111111111111111111111111111111111112": { + "value": "So11111111111111111111111111111111111111112" + }, + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + }, + "in": "path", + "name": "address", "required": true, - "description": "Position NFT token ID" + "description": "Token contract address" } ], "responses": { @@ -10899,69 +4720,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] + "$ref": "#/components/schemas/Token" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10969,29 +4748,56 @@ } } }, - "/connectors/uniswap/clmm/positions-owned": { + "/tokens/": { "get": { - "tags": ["/connector/uniswap"], - "description": "Get all Uniswap V3 positions owned by a wallet", + "operationId": "listTokens", + "tags": [ + "/tokens" + ], + "description": "List tokens from token lists with optional filtering", "parameters": [ { "schema": { - "default": "base", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "example": "base", + "example": "solana-mainnet-beta", "in": "query", - "name": "network", - "required": false + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "examples": { + "USDC": { + "value": "USDC" + }, + "USD": { + "value": "USD" + } + }, "in": "query", - "name": "walletAddress", - "required": true + "name": "search", + "required": false, + "description": "Search term for filtering tokens by symbol or name" } ], "responses": { @@ -11000,151 +4806,90 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Token" } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } + } + }, + "required": [ + "tokens" + ] } } } - } - } - } - }, - "/connectors/uniswap/clmm/quote-position": { - "get": { - "tags": ["/connector/uniswap"], - "description": "Get a quote for opening a position on Uniswap V3", - "parameters": [ - { - "schema": { - "type": "string", - "default": "base" - }, - "example": "base", - "in": "query", - "name": "network", - "required": false - }, - { - "schema": { - "type": "number" - }, - "example": 2000, - "in": "query", - "name": "lowerPrice", - "required": true - }, - { - "schema": { - "type": "number" - }, - "example": 4000, - "in": "query", - "name": "upperPrice", - "required": true - }, - { - "schema": { - "type": "string", - "default": "0xd0b53d9277642d899df5c87a3966a349a798f224" - }, - "example": "0xd0b53d9277642d899df5c87a3966a349a798f224", - "in": "query", - "name": "poolAddress", - "required": true - }, - { - "schema": { - "type": "number" - }, - "example": 0.001, - "in": "query", - "name": "baseTokenAmount", - "required": false }, - { - "schema": { - "type": "number" - }, - "example": 3, - "in": "query", - "name": "quoteTokenAmount", - "required": false + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "type": "number" - }, - "in": "query", - "name": "slippagePct", - "required": false + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } + } + }, + "post": { + "operationId": "addToken", + "tags": [ + "/tokens" ], + "description": "Add a new token to a token list", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "type": "string", + "example": "solana-mainnet-beta" + }, + "token": { + "$ref": "#/components/schemas/Token" + } + }, + "required": [ + "chainNetwork", + "token" + ] + } + } + }, + "required": true + }, "responses": { "200": { "description": "Default Response", @@ -11153,105 +4898,88 @@ "schema": { "type": "object", "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} + "message": { + "description": "Success message", + "type": "string" + } }, "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" + "message" ] } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/connectors/uniswap/clmm/quote-swap": { - "get": { - "tags": ["/connector/uniswap"], - "description": "Get swap quote for Uniswap V3 CLMM", + "/tokens/save/{address}": { + "post": { + "operationId": "saveToken", + "tags": [ + "/tokens" + ], + "description": "Find token from GeckoTerminal and save it to the token list", "parameters": [ - { - "schema": { - "type": "string", - "default": "base" - }, - "in": "query", - "name": "network", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "poolAddress", - "required": false, - "description": "Pool address (optional - can be looked up from baseToken and quoteToken)" - }, - { - "schema": { - "type": "string" - }, - "example": "WETH", - "in": "query", - "name": "baseToken", - "required": true - }, { "schema": { "type": "string" }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false - }, - { - "schema": { - "type": "number" - }, - "example": 0.001, - "in": "query", - "name": "amount", - "required": true - }, - { - "schema": { - "type": "string", - "enum": ["BUY", "SELL"] + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } }, - "example": "SELL", "in": "query", - "name": "side", - "required": true + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { - "type": "number" + "type": "string" }, - "example": 1, - "in": "query", - "name": "slippagePct", - "required": false + "examples": { + "So11111111111111111111111111111111111111112": { + "value": "So11111111111111111111111111111111111111112" + }, + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + }, + "in": "path", + "name": "address", + "required": true, + "description": "Token contract address" } ], "responses": { @@ -11262,172 +4990,37 @@ "schema": { "type": "object", "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { + "message": { "type": "string" }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" + "token": { + "$ref": "#/components/schemas/Token" } }, "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" + "message", + "token" ] } } } - } - } - } - }, - "/connectors/uniswap/clmm/execute-swap": { - "post": { - "tags": ["/connector/uniswap"], - "description": "Execute a swap on Uniswap V3 CLMM using SwapRouter02", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "baseToken": { - "description": "Token to determine swap direction", - "type": "string", - "example": "WETH" - }, - "quoteToken": { - "description": "The other token in the pair", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount of base token to trade", - "type": "number", - "example": 0.001 - }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": ["BUY", "SELL"], - "type": "string" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 1 - } - }, - "required": ["baseToken", "quoteToken", "amount", "side"] + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11435,55 +5028,50 @@ } } }, - "/connectors/uniswap/clmm/open-position": { - "post": { - "tags": ["/connector/uniswap"], - "description": "Open a new liquidity position in a Uniswap V3 pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "type": "string", - "default": "base" - }, - "walletAddress": { - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "lowerPrice": { - "type": "number", - "example": 1000 - }, - "upperPrice": { - "type": "number", - "example": 4000 - }, - "poolAddress": { - "type": "string", - "example": "0xd0b53d9277642d899df5c87a3966a349a798f224" - }, - "baseTokenAmount": { - "type": "number", - "example": 0.001 - }, - "quoteTokenAmount": { - "type": "number", - "example": 3 - }, - "slippagePct": { - "type": "number", - "example": 1 - } - }, - "required": ["lowerPrice", "upperPrice", "poolAddress"] - } - } + "/tokens/{address}": { + "delete": { + "operationId": "removeToken", + "tags": [ + "/tokens" + ], + "description": "Remove a token from a token list by address", + "parameters": [ + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, - "required": true - }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "address", + "required": true, + "description": "Token address to remove" + } + ], "responses": { "200": { "description": "Default Response", @@ -11492,42 +5080,34 @@ "schema": { "type": "object", "properties": { - "signature": { + "message": { + "description": "Success message", "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] } }, - "required": ["signature", "status"] + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11535,62 +5115,102 @@ } } }, - "/connectors/uniswap/clmm/add-liquidity": { - "post": { - "tags": ["/connector/uniswap"], - "description": "Add liquidity to an existing Uniswap V3 position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The EVM network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], - "type": "string" - }, - "walletAddress": { - "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "positionAddress": { - "description": "NFT token ID of the position", - "type": "string" - }, - "baseTokenAmount": { - "description": "Amount of base token to add", - "type": "number" - }, - "quoteTokenAmount": { - "description": "Amount of quote token to add", - "type": "number" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 - } - }, - "required": ["positionAddress", "baseTokenAmount", "quoteTokenAmount"] + "/pools/{tradingPair}": { + "get": { + "operationId": "getPool", + "tags": [ + "/pools" + ], + "description": "Get a specific pool by trading pair", + "parameters": [ + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "enum": [ + "amm", + "clmm" + ], + "type": "string" + }, + "examples": { + "amm": { + "value": "amm" + }, + "clmm": { + "value": "clmm" } - } + }, + "in": "query", + "name": "type", + "required": true, + "description": "Pool type" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "raydium": { + "value": "raydium" + }, + "meteora": { + "value": "meteora" + }, + "uniswap": { + "value": "uniswap" + }, + "orca": { + "value": "orca" + } + }, + "in": "query", + "name": "connector", + "required": false, + "description": "Optional: filter by connector (raydium, meteora, uniswap, orca)" }, - "required": true - }, + { + "schema": { + "type": "string" + }, + "examples": { + "SOL-USDC": { + "value": "SOL-USDC" + }, + "ETH-USDC": { + "value": "ETH-USDC" + } + }, + "in": "path", + "name": "tradingPair", + "required": true, + "description": "Trading pair (e.g., SOL-USDC, ETH-USDC)" + } + ], "responses": { "200": { "description": "Default Response", @@ -11599,105 +5219,74 @@ "schema": { "type": "object", "properties": { - "signature": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { "type": "string" }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" + "baseSymbol": { + "type": "string" }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "format": "decimal", + "type": "string" + }, + "address": { + "type": "string" } }, - "required": ["signature", "status"] + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] } } } - } - } - } - }, - "/connectors/uniswap/clmm/remove-liquidity": { - "post": { - "tags": ["/connector/uniswap"], - "description": "Remove liquidity from a Uniswap V3 position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "type": "string", - "default": "base" - }, - "walletAddress": { - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "positionAddress": { - "type": "string", - "description": "Position NFT token ID", - "example": "1234" - }, - "percentageToRemove": { - "type": "number", - "minimum": 0, - "maximum": 100, - "example": 50 - } - }, - "required": ["positionAddress", "percentageToRemove"] + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11705,36 +5294,55 @@ } } }, - "/connectors/uniswap/clmm/collect-fees": { - "post": { - "tags": ["/connector/uniswap"], - "description": "Collect fees from a Uniswap V3 position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "type": "string", - "default": "base" - }, - "walletAddress": { - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "positionAddress": { - "type": "string", - "description": "Position NFT token ID", - "example": "1234" - } - }, - "required": ["positionAddress"] + "/pools/find/{address}": { + "get": { + "operationId": "findPool", + "tags": [ + "/pools" + ], + "description": "Get detailed pool information by address from GeckoTerminal", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" } - } + }, + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, - "required": true - }, + { + "schema": { + "type": "string" + }, + "examples": { + "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2": { + "value": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" + }, + "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640": { + "value": "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" + } + }, + "in": "path", + "name": "address", + "required": true, + "description": "Pool contract address" + } + ], "responses": { "200": { "description": "Default Response", @@ -11743,111 +5351,74 @@ "schema": { "type": "object", "properties": { - "signature": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { "type": "string" }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" + "baseSymbol": { + "type": "string" }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "format": "decimal", + "type": "string" + }, + "address": { + "type": "string" } }, - "required": ["signature", "status"] + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] } } } - } - } - } - }, - "/connectors/uniswap/clmm/close-position": { - "post": { - "tags": ["/connector/uniswap"], - "description": "Close a Uniswap V3 position by removing all liquidity and collecting fees", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "positionAddress": { - "type": "string" - } - }, - "required": ["positionAddress"] - } - } }, - "required": true - }, - "responses": { - "200": { + "400": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11855,92 +5426,139 @@ } } }, - "/connectors/0x/router/quote-swap": { + "/pools/find": { "get": { - "tags": ["/connector/0x"], - "description": "Get a swap quote from 0x. Use indicativePrice=true for price discovery only, or false/undefined for executable quotes", + "operationId": "findPools", + "tags": [ + "/pools" + ], + "description": "Find pools for a token pair from GeckoTerminal", "parameters": [ { "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "mainnet", "optimism", "polygon"], "type": "string" }, - "in": "query", - "name": "network", - "required": false, - "description": "The EVM network to use" - }, - { - "schema": { - "type": "string" + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } }, - "example": "WETH", "in": "query", - "name": "baseToken", + "name": "chainNetwork", "required": true, - "description": "First token in the trading pair" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": true, - "description": "Second token in the trading pair" - }, - { - "schema": { - "type": "number" + "examples": { + "raydium": { + "value": "raydium" + }, + "meteora": { + "value": "meteora" + }, + "uniswap": { + "value": "uniswap" + }, + "pancakeswap": { + "value": "pancakeswap" + }, + "pancakeswap-sol": { + "value": "pancakeswap-sol" + }, + "orca": { + "value": "orca" + } }, - "example": 1, "in": "query", - "name": "amount", - "required": true, - "description": "Amount of base token to trade" + "name": "connector", + "required": false, + "description": "Filter by connector name (e.g., raydium, meteora, uniswap, pancakeswap, pancakeswap-sol)" }, { "schema": { - "enum": ["BUY", "SELL"], + "enum": [ + "clmm", + "amm" + ], + "default": "clmm", "type": "string" }, + "examples": { + "clmm": { + "value": "clmm" + }, + "amm": { + "value": "amm" + } + }, "in": "query", - "name": "side", - "required": true, - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" + "name": "type", + "required": false, + "description": "Filter by pool type: clmm (v3-style concentrated liquidity) or amm (v2-style)" }, { "schema": { - "minimum": 0, - "maximum": 100, - "type": "number" + "type": "string" + }, + "examples": { + "SOL": { + "value": "SOL" + }, + "So11111111111111111111111111111111111111112": { + "value": "So11111111111111111111111111111111111111112" + }, + "USDC": { + "value": "USDC" + } }, - "example": 1, "in": "query", - "name": "slippagePct", + "name": "tokenA", "required": false, - "description": "Maximum acceptable slippage percentage" + "description": "First token symbol or contract address (optional - for filtering by token pair)" }, { "schema": { - "default": true, - "type": "boolean" + "type": "string" + }, + "examples": { + "USDC": { + "value": "USDC" + }, + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { + "value": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + }, + "SOL": { + "value": "SOL" + } }, "in": "query", - "name": "indicativePrice", + "name": "tokenB", "required": false, - "description": "If true, returns indicative pricing only (no commitment). If false, returns firm quote ready for execution" + "description": "Second token symbol or contract address (optional - for filtering by token pair)" }, { "schema": { - "type": "string" + "minimum": 1, + "maximum": 10, + "default": 10, + "type": "number" }, "in": "query", - "name": "takerAddress", + "name": "pages", "required": false, - "description": "Ethereum wallet address that will execute the swap (optional for quotes)" + "description": "Number of pages to fetch from GeckoTerminal (1-10, default: 10)" } ], "responses": { @@ -11949,329 +5567,79 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "quoteId": { - "description": "Unique identifier for this quote", - "type": "string" - }, - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "expirationTime": { - "description": "Unix timestamp when this quote expires (only for firm quotes)", - "type": "number" - }, - "gasEstimate": { - "description": "Estimated gas required for the swap", - "type": "string" - }, - "sources": { - "description": "Liquidity sources used for this quote", - "type": "array", - "items": {} - }, - "allowanceTarget": { - "description": "Contract address that needs token approval", - "type": "string" - }, - "to": { - "description": "Contract address to send transaction to", - "type": "string" - }, - "data": { - "description": "Encoded transaction data", - "type": "string" - }, - "value": { - "description": "ETH value to send with transaction", - "type": "string" - } - }, - "required": [ - "quoteId", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn", - "gasEstimate" - ] - } - } - } - } - } - } - }, - "/connectors/0x/router/execute-quote": { - "post": { - "tags": ["/connector/0x"], - "description": "Execute a previously fetched quote from 0x", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "mainnet", "optimism", "polygon"], - "type": "string", - "example": "arbitrum" - }, - "quoteId": { - "description": "ID of the quote to execute", - "type": "string", - "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 1000000 - } - }, - "required": ["quoteId"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] - } - } - } - } - } - } - }, - "/connectors/0x/router/execute-swap": { - "post": { - "tags": ["/connector/0x"], - "description": "Quote and execute a token swap on 0x in one step", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "mainnet", "optimism", "polygon"], - "type": "string", - "example": "arbitrum" - }, - "baseToken": { - "description": "Token to determine swap direction", - "type": "string", - "example": "WETH" - }, - "quoteToken": { - "description": "The other token in the pair", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount of base token to trade", - "type": "number", - "example": 1 - }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": ["BUY", "SELL"], - "type": "string" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "type": "number", - "example": 1 - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "format": "decimal", + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] } - }, - "required": ["baseToken", "quoteToken", "amount", "side"] + } } } }, - "required": true - }, - "responses": { - "200": { + "400": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12279,83 +5647,92 @@ } } }, - "/connectors/pancakeswap/router/quote-swap": { + "/pools/": { "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get an executable swap quote from Pancakeswap Universal Router", + "operationId": "listPools", + "tags": [ + "/pools" + ], + "description": "List all pools for a chain/network, optionally filtered by connector, type, or search term", "parameters": [ { "schema": { - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The EVM network to use" - }, - { - "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "example": "USDT", + "example": "solana-mainnet-beta", "in": "query", - "name": "baseToken", + "name": "chainNetwork", "required": true, - "description": "First token in the trading pair" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "WBNB", - "in": "query", - "name": "quoteToken", - "required": true, - "description": "Second token in the trading pair" - }, - { - "schema": { - "type": "number" + "examples": { + "raydium": { + "value": "raydium" + }, + "meteora": { + "value": "meteora" + }, + "uniswap": { + "value": "uniswap" + }, + "orca": { + "value": "orca" + } }, - "example": 10, "in": "query", - "name": "amount", - "required": true, - "description": "Amount of base token to trade" + "name": "connector", + "required": false, + "description": "Optional: filter by connector (raydium, meteora, uniswap, orca)" }, { "schema": { - "enum": ["BUY", "SELL"], + "enum": [ + "clmm", + "amm" + ], "type": "string" }, - "in": "query", - "name": "side", - "required": true, - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" + "examples": { + "clmm": { + "value": "clmm" + }, + "amm": { + "value": "amm" + } }, "in": "query", - "name": "slippagePct", + "name": "type", "required": false, - "description": "Maximum acceptable slippage percentage" + "description": "Optional: filter by pool type" }, { "schema": { - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", "type": "string" }, "in": "query", - "name": "walletAddress", + "name": "search", "required": false, - "description": "Wallet address for more accurate quotes (optional)" + "description": "Optional: search by token symbol or address" } ], "responses": { @@ -12364,220 +5741,173 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "quoteId": { - "description": "Unique identifier for this quote", - "type": "string" - }, - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "format": "decimal", + "type": "string" + }, + "address": { + "type": "string" + } }, - "routePath": { - "description": "Human-readable route path", - "type": "string" - } - }, - "required": [ - "quoteId", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn" - ] + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] + } } } } - } - } - } - }, - "/connectors/pancakeswap/router/execute-quote": { - "post": { - "tags": ["/connector/pancakeswap"], - "description": "Execute a previously fetched quote from Pancakeswap Universal Router", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string" - }, - "quoteId": { - "description": "ID of the quote to execute", - "type": "string", - "example": "123e4567-e89b-12d3-a456-426614174000" - } - }, - "required": ["quoteId"] + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } } } - } - }, - "/connectors/pancakeswap/router/execute-swap": { + }, "post": { - "tags": ["/connector/pancakeswap"], - "description": "Quote and execute a token swap on Pancakeswap Universal Router in one step", + "operationId": "addPool", + "tags": [ + "/pools" + ], + "description": "Add a new pool", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Connector (raydium, meteora, uniswap, orca)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "clmm" }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], + "address": { + "description": "Pool contract address", "type": "string" }, - "baseToken": { - "description": "Token to determine swap direction", + "baseSymbol": { + "description": "Base token symbol (optional - fetched automatically if not provided)", "type": "string", - "example": "USDT" + "example": "SOL" }, - "quoteToken": { - "description": "The other token in the pair", + "quoteSymbol": { + "description": "Quote token symbol (optional - fetched automatically if not provided)", "type": "string", - "example": "WBNB" + "example": "USDC" }, - "amount": { - "description": "Amount of base token to trade", - "type": "number", - "example": 10 + "baseTokenAddress": { + "description": "Base token contract address", + "type": "string", + "example": "So11111111111111111111111111111111111111112" }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": ["BUY", "SELL"], - "type": "string" + "quoteTokenAddress": { + "description": "Quote token contract address", + "type": "string", + "example": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, - "slippagePct": { + "feePct": { + "format": "decimal", + "description": "Pool fee percentage (optional - fetched from pool-info if not provided)", "minimum": 0, "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, "type": "number", - "example": 1 + "example": 0.25 } }, - "required": ["baseToken", "quoteToken", "amount", "side"] + "required": [ + "chainNetwork", + "connector", + "type", + "address", + "baseTokenAddress", + "quoteTokenAddress" + ] } } }, @@ -12591,58 +5921,33 @@ "schema": { "type": "object", "properties": { - "signature": { - "description": "Transaction signature/hash", + "message": { "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] } }, - "required": ["signature", "status"] + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12650,31 +5955,53 @@ } } }, - "/connectors/pancakeswap/amm/pool-info": { - "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get AMM pool information from Pancakeswap V2", + "/pools/save/{address}": { + "post": { + "operationId": "savePool", + "tags": [ + "/pools" + ], + "description": "Find pool from GeckoTerminal and save it to the pool list. Auto-adds missing tokens.", "parameters": [ { "schema": { - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], "type": "string" }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } + }, "in": "query", - "name": "network", - "required": false, - "description": "The EVM network to use" + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C", - "in": "query", - "name": "poolAddress", + "examples": { + "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2": { + "value": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" + }, + "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640": { + "value": "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" + } + }, + "in": "path", + "name": "address", "required": true, - "description": "Pancakeswap V2 pool address" + "description": "Pool contract address" } ], "responses": { @@ -12685,93 +6012,141 @@ "schema": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { + "message": { "type": "string" }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" + "pool": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "format": "decimal", + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] }, - "quoteTokenAmount": { - "type": "number" + "tokensAdded": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount" + "message", + "pool" ] } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/connectors/pancakeswap/amm/position-info": { - "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get position information for a Pancakeswap V2 pool", + "/pools/{address}": { + "delete": { + "operationId": "removePool", + "tags": [ + "/pools" + ], + "description": "Remove a pool by address", "parameters": [ { "schema": { - "type": "string", - "default": "base" - }, - "in": "query", - "name": "network", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "in": "query", - "name": "walletAddress", - "required": false - }, - { - "schema": { - "type": "string" - }, - "example": "", - "in": "query", - "name": "poolAddress", - "required": true - }, - { - "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "example": "WETH", + "example": "solana-mainnet-beta", "in": "query", - "name": "baseToken", - "required": false + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": false + "in": "path", + "name": "address", + "required": true, + "description": "Pool contract address to remove" } ], "responses": { @@ -12782,116 +6157,188 @@ "schema": { "type": "object", "properties": { - "poolAddress": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { + "message": { "type": "string" - }, - "lpTokenAmount": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "price": { - "type": "number" } }, "required": [ - "poolAddress", - "walletAddress", - "baseTokenAddress", - "quoteTokenAddress", - "lpTokenAmount", - "baseTokenAmount", - "quoteTokenAmount", - "price" + "message" ] } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/connectors/pancakeswap/amm/quote-swap": { + "/trading/router/quote-swap": { "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get swap quote for Pancakeswap V2 AMM", + "operationId": "quoteRouterSwap", + "tags": [ + "/trading/router" + ], + "description": "Get a swap quote from a router connector on any supported chain", "parameters": [ { "schema": { - "type": "string", - "default": "base" + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", - "required": false + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], "type": "string" }, - "example": "", + "example": "jupiter", "in": "query", - "name": "poolAddress", - "required": false + "name": "connector", + "required": false, + "description": "Router connector. Defaults to the network's swapProvider" }, { "schema": { + "default": "SOL", "type": "string" }, - "example": "WETH", "in": "query", "name": "baseToken", - "required": true + "required": true, + "description": "Symbol or address of the base token" }, { "schema": { + "default": "USDC", "type": "string" }, - "example": "USDC", "in": "query", "name": "quoteToken", - "required": false + "required": true, + "description": "Symbol or address of the quote token" }, { "schema": { + "format": "decimal", + "default": 1, "type": "number" }, - "example": 0.001, "in": "query", "name": "amount", - "required": true + "required": true, + "description": "Amount of base token to trade" }, { "schema": { - "type": "string", - "enum": ["BUY", "SELL"] + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" }, - "example": "SELL", "in": "query", "name": "side", - "required": true + "required": true, + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token" }, { "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, "type": "number" }, "example": 1, "in": "query", "name": "slippagePct", - "required": false + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": false, + "description": "Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata." + }, + { + "schema": { + "default": true, + "x-connectors": [ + "jupiter", + "dflow", + "okx", + "titan" + ], + "type": "boolean" + }, + "in": "query", + "name": "approximateIfNoExactOut", + "required": false, + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing." + }, + { + "schema": { + "x-connectors": [ + "0x" + ], + "type": "boolean" + }, + "in": "query", + "name": "indicativePrice", + "required": false, + "description": "Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote." } ], "responses": { @@ -12900,50 +6347,127 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" - ] + "$ref": "#/components/schemas/RouterQuoteSwapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/router/execute-quote": { + "post": { + "operationId": "executeRouterQuote", + "tags": [ + "/trading/router" + ], + "description": "Execute a previously fetched router quote by its quote id", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouterExecuteQuoteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChainExecuteSwapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/router/execute-swap": { + "post": { + "operationId": "executeRouterSwap", + "tags": [ + "/trading/router" + ], + "description": "Quote and execute a swap through a router connector on any supported chain", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouterExecuteSwapRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChainExecuteSwapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12951,73 +6475,180 @@ } } }, - "/connectors/pancakeswap/amm/quote-liquidity": { + "/trading/clmm/pool-info": { "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get liquidity quote for a Pancakeswap V2 pool", + "operationId": "getClmmPoolInfo", + "tags": [ + "/trading/clmm" + ], + "description": "Get CLMM pool information from any supported connector", "parameters": [ { "schema": { - "type": "string", - "default": "base" + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" }, + "example": "meteora", "in": "query", - "name": "network", - "required": false - }, - { - "schema": { + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", "type": "string" }, - "example": "", + "example": "solana-mainnet-beta", "in": "query", - "name": "poolAddress", - "required": true + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { - "type": "number" + "type": "string" }, - "example": 0.001, + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", "in": "query", - "name": "baseTokenAmount", - "required": true + "name": "poolAddress", + "required": true, + "description": "Pool contract address" }, { "schema": { - "type": "number" + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" }, - "example": 2.5, "in": "query", - "name": "quoteTokenAmount", - "required": true + "name": "binCount", + "required": false, + "description": "If > 0, include a `bins` array of per-tick liquidity around the active tick. Supported by every connector except Meteora, which always returns its bins and ignores this. Default 0 = skip the bin fetch." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClmmPoolInfo" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/clmm/position-info": { + "get": { + "operationId": "getClmmPositionInfo", + "tags": [ + "/trading/clmm" + ], + "description": "Get CLMM position information from any supported connector", + "parameters": [ { "schema": { - "type": "number" + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" }, - "example": 1, + "example": "meteora", "in": "query", - "name": "slippagePct", - "required": false - }, - { - "schema": { + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", "type": "string" }, - "example": "WETH", + "example": "solana-mainnet-beta", "in": "query", - "name": "baseToken", - "required": false + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "USDC", + "example": "", "in": "query", - "name": "quoteToken", - "required": false + "name": "positionAddress", + "required": true, + "description": "Position address or NFT token ID" } ], "responses": { @@ -13026,158 +6657,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - } - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] + "$ref": "#/components/schemas/ClmmPositionInfo" } } } - } - } - } - }, - "/connectors/pancakeswap/amm/execute-swap": { - "post": { - "tags": ["/connector/pancakeswap"], - "description": "Execute a swap on Pancakeswap V2 AMM using Router02", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "network": { - "description": "The EVM network to use", - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string" - }, - "poolAddress": { - "description": "Pool address (optional - can be looked up from tokens)", - "default": "", - "type": "string" - }, - "baseToken": { - "description": "Base token symbol or address", - "type": "string", - "example": "USDT" - }, - "quoteToken": { - "description": "Quote token symbol or address", - "type": "string", - "example": "WBNB" - }, - "amount": { - "description": "Amount to swap", - "type": "number", - "example": 10 - }, - "side": { - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number" - } - }, - "required": ["baseToken", "amount", "side"] + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13185,180 +6685,101 @@ } } }, - "/connectors/pancakeswap/amm/add-liquidity": { - "post": { - "tags": ["/connector/pancakeswap"], - "description": "Add liquidity to a Pancakeswap V2 pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The EVM network to use", - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string" - }, - "walletAddress": { - "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "poolAddress": { - "description": "Address of the Pancakeswap V2 pool", - "type": "string" - }, - "baseTokenAmount": { - "description": "Amount of base token to add", - "type": "number" - }, - "quoteTokenAmount": { - "description": "Amount of quote token to add", - "type": "number" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 - } - }, - "required": ["poolAddress", "baseTokenAmount", "quoteTokenAmount"] - } - } + "/trading/clmm/positions-owned": { + "get": { + "operationId": "listClmmPositions", + "tags": [ + "/trading/clmm" + ], + "description": "Get all CLMM positions owned by a wallet from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, - "required": true - }, + { + "schema": { + "default": "", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": true, + "description": "Wallet address" + } + ], "responses": { "200": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] + "type": "array", + "items": { + "$ref": "#/components/schemas/ClmmPositionInfo" + } } } } - } - } - } - }, - "/connectors/pancakeswap/amm/remove-liquidity": { - "post": { - "tags": ["/connector/pancakeswap"], - "description": "Remove liquidity from a Pancakeswap V2 pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The EVM network to use", - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string" - }, - "walletAddress": { - "description": "Wallet address that will remove liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "poolAddress": { - "description": "Address of the Pancakeswap V2 pool", - "type": "string" - }, - "percentageToRemove": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 - } - }, - "required": ["poolAddress", "percentageToRemove"] + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13366,32 +6787,126 @@ } } }, - "/connectors/pancakeswap/clmm/pool-info": { + "/trading/clmm/quote-liquidity": { "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get CLMM pool information from Pancakeswap V3", + "operationId": "quoteClmmLiquidity", + "tags": [ + "/trading/clmm" + ], + "description": "Quote amounts for a new CLMM position from any supported connector", "parameters": [ { "schema": { - "default": "bsc", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string" + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "example": 150, + "in": "query", + "name": "lowerPrice", + "required": true, + "description": "Lower price bound for the position" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "example": 250, + "in": "query", + "name": "upperPrice", + "required": true, + "description": "Upper price bound for the position" + }, + { + "schema": { + "type": "string" + }, + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Pool contract address" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "example": 0.01, + "in": "query", + "name": "baseTokenAmount", + "required": false, + "description": "Amount of base token to deposit" + }, + { + "schema": { + "format": "decimal", + "type": "number" }, - "example": "bsc", + "example": 2, "in": "query", - "name": "network", + "name": "quoteTokenAmount", "required": false, - "description": "The EVM network to use" + "description": "Amount of quote token to deposit" }, { "schema": { - "type": "string" + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" }, - "example": "0x172fcd41e0913e95784454622d1c3724f546f849", + "example": 1, "in": "query", - "name": "poolAddress", - "required": true, - "description": "Pancakeswap V3 pool address" + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." } ], "responses": { @@ -13400,148 +6915,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ] + "$ref": "#/components/schemas/ClmmQuoteLiquidityResponse" } } } - } - } - } - }, - "/connectors/pancakeswap/clmm/position-info": { - "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get position information for a Pancakeswap V3 position", - "parameters": [ - { - "schema": { - "type": "string", - "default": "bsc" - }, - "example": "bsc", - "in": "query", - "name": "network", - "required": false }, - { - "schema": { - "type": "string" - }, - "example": "1234", - "in": "query", - "name": "positionAddress", - "required": true, - "description": "Position NFT token ID" - } - ], - "responses": { - "200": { + "400": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13549,180 +6943,154 @@ } } }, - "/connectors/pancakeswap/clmm/positions-owned": { + "/trading/clmm/fetch-pools": { "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get all Pancakeswap V3 positions owned by a wallet", + "operationId": "fetchClmmPools", + "tags": [ + "/trading/clmm" + ], + "description": "Discover pools from a CLMM connector's own pool-listing API", "parameters": [ { "schema": { - "default": "bsc", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", "type": "string" }, - "example": "bsc", + "example": "solana-mainnet-beta", "in": "query", - "name": "network", - "required": false + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { + "enum": [ + "meteora", + "orca" + ], + "default": "meteora", "type": "string" }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "example": "meteora", "in": "query", - "name": "walletAddress", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } - } - } - } - } - } - } - }, - "/connectors/pancakeswap/clmm/quote-position": { - "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get a quote for opening a position on Pancakeswap V3", - "parameters": [ + "name": "connector", + "required": true, + "description": "CLMM connector whose pool-discovery API to query" + }, { "schema": { - "type": "string", - "default": "bsc" + "minimum": 1, + "maximum": 1000, + "default": 50, + "type": "number" }, - "example": "bsc", "in": "query", - "name": "network", - "required": false + "name": "limit", + "required": false, + "description": "Maximum number of pools to return" }, { "schema": { - "type": "number" + "type": "string" + }, + "examples": { + "SOL": { + "value": "SOL" + }, + "SOL-USDC": { + "value": "SOL-USDC" + } }, - "example": 0.0008, "in": "query", - "name": "lowerPrice", - "required": true + "name": "query", + "required": false, + "description": "Search pools by name, token, or address" }, { "schema": { - "type": "number" + "type": "string" + }, + "examples": { + "tvl": { + "value": "tvl" + }, + "tvl:desc": { + "value": "tvl:desc" + } }, - "example": 0.001, "in": "query", - "name": "upperPrice", - "required": true + "name": "sortBy", + "required": false, + "description": "Sort field. Meteora takes a \"field:direction\" pair; Orca takes the field alone with sortDirection." }, { "schema": { - "type": "string", - "default": "0x172fcd41e0913e95784454622d1c3724f546f849" + "minimum": 0, + "x-connectors": [ + "meteora" + ], + "type": "number" }, - "example": "0x172fcd41e0913e95784454622d1c3724f546f849", "in": "query", - "name": "poolAddress", - "required": true + "name": "page", + "required": false, + "description": "0-based page index. Only connectors whose API paginates honor this." }, { "schema": { - "type": "number" + "x-connectors": [ + "meteora" + ], + "type": "boolean" }, - "example": 10, "in": "query", - "name": "baseTokenAmount", - "required": false + "name": "includeUnverified", + "required": false, + "description": "Include unverified pools" }, { "schema": { - "type": "number" + "enum": [ + "asc", + "desc" + ], + "x-connectors": [ + "orca" + ], + "type": "string" }, - "example": 0.01, "in": "query", - "name": "quoteTokenAmount", - "required": false + "name": "sortDirection", + "required": false, + "description": "Sort direction" }, { "schema": { - "minimum": 0, - "maximum": 100, - "type": "number" + "x-connectors": [ + "orca" + ], + "type": "boolean" }, "in": "query", - "name": "slippagePct", - "required": false + "name": "verifiedOnly", + "required": false, + "description": "Return only verified pools" } ], "responses": { @@ -13731,32 +7099,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] + "$ref": "#/components/schemas/ClmmFetchPoolsResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13764,75 +7127,126 @@ } } }, - "/connectors/pancakeswap/clmm/quote-swap": { + "/trading/clmm/quote-swap": { "get": { - "tags": ["/connector/pancakeswap"], - "description": "Get swap quote for Pancakeswap V3 CLMM", + "operationId": "quoteClmmSwap", + "tags": [ + "/trading/clmm" + ], + "description": "Get a swap quote from a single CLMM pool", "parameters": [ { "schema": { - "type": "string", - "default": "bsc" + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" }, - "example": "bsc", + "example": "solana-mainnet-beta", "in": "query", - "name": "network", - "required": false + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", "type": "string" }, + "example": "meteora", "in": "query", - "name": "poolAddress", + "name": "connector", "required": false, - "description": "Pool address (optional - can be looked up from baseToken and quoteToken)" + "description": "CLMM connector to price the swap against" }, { "schema": { + "default": "SOL", "type": "string" }, - "example": "USDT", "in": "query", "name": "baseToken", - "required": true + "required": true, + "description": "Symbol or address of the base token" }, { "schema": { + "default": "USDC", "type": "string" }, - "example": "WBNB", "in": "query", "name": "quoteToken", - "required": false + "required": true, + "description": "Symbol or address of the quote token" }, { "schema": { + "format": "decimal", + "default": 1, "type": "number" }, - "example": 10, "in": "query", "name": "amount", - "required": true + "required": true, + "description": "Amount of base token to trade" }, { "schema": { - "type": "string", - "enum": ["BUY", "SELL"] + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" }, - "example": "SELL", "in": "query", "name": "side", - "required": true + "required": true, + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": false, + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list." }, { "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, "type": "number" }, "example": 1, "in": "query", "name": "slippagePct", - "required": false + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." } ], "responses": { @@ -13841,50 +7255,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" - ] + "$ref": "#/components/schemas/ChainQuoteSwapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13892,62 +7283,71 @@ } } }, - "/connectors/pancakeswap/clmm/execute-swap": { + "/trading/clmm/execute-swap": { "post": { - "tags": ["/connector/pancakeswap"], - "description": "Execute a swap on Pancakeswap V3 CLMM using SwapRouter02", + "operationId": "executeClmmSwap", + "tags": [ + "/trading/clmm" + ], + "description": "Execute a swap against a single CLMM pool", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "network": { - "description": "The blockchain network to use", - "default": "mainnet", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string" - }, - "baseToken": { - "description": "Token to determine swap direction", - "type": "string", - "example": "USDT" - }, - "quoteToken": { - "description": "The other token in the pair", - "type": "string", - "example": "WBNB" - }, - "amount": { - "description": "Amount of base token to trade", - "type": "number", - "example": 10 - }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": ["BUY", "SELL"], - "type": "string" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 1 - } - }, - "required": ["baseToken", "quoteToken", "amount", "side"] + "$ref": "#/components/schemas/ClmmExecuteSwapRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChainExecuteSwapResponse" + } } } }, - "required": true + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/clmm/open": { + "post": { + "operationId": "openClmmPosition", + "tags": [ + "/trading/clmm" + ], + "description": "Open a new CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClmmOpenRequest" + } + } + } }, "responses": { "200": { @@ -13955,60 +7355,127 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ClmmOpenPositionResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/clmm/add": { + "post": { + "operationId": "addClmmLiquidity", + "tags": [ + "/trading/clmm" + ], + "description": "Add liquidity to an existing CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClmmAddRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClmmAddLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/clmm/remove": { + "post": { + "operationId": "removeClmmLiquidity", + "tags": [ + "/trading/clmm" + ], + "description": "Remove liquidity from a CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClmmRemoveRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClmmRemoveLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14016,56 +7483,21 @@ } } }, - "/connectors/pancakeswap/clmm/open-position": { + "/trading/clmm/collect-fees": { "post": { - "tags": ["/connector/pancakeswap"], - "description": "Open a new liquidity position in a Pancakeswap V3 pool", + "operationId": "collectClmmFees", + "tags": [ + "/trading/clmm" + ], + "description": "Collect fees from a CLMM position across supported connectors", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "type": "string", - "default": "bsc", - "example": "bsc" - }, - "walletAddress": { - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "lowerPrice": { - "type": "number", - "example": 0.0008 - }, - "upperPrice": { - "type": "number", - "example": 0.001 - }, - "poolAddress": { - "type": "string", - "default": "0x172fcd41e0913e95784454622d1c3724f546f849", - "example": "0x172fcd41e0913e95784454622d1c3724f546f849" - }, - "baseTokenAmount": { - "type": "number", - "example": 10 - }, - "quoteTokenAmount": { - "type": "number", - "example": 0.01 - }, - "slippagePct": { - "type": "number", - "example": 1 - } - }, - "required": ["lowerPrice", "upperPrice", "poolAddress"] + "$ref": "#/components/schemas/ClmmCollectFeesRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -14073,140 +7505,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ClmmCollectFeesResponse" } } } - } - } - } - }, - "/connectors/pancakeswap/clmm/add-liquidity": { - "post": { - "tags": ["/connector/pancakeswap"], - "description": "Add liquidity to an existing Pancakeswap V3 position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The EVM network to use", - "default": "bsc", - "enum": ["arbitrum", "base", "bsc", "mainnet"], - "type": "string", - "example": "bsc" - }, - "walletAddress": { - "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "positionAddress": { - "description": "NFT token ID of the position", - "type": "string" - }, - "baseTokenAmount": { - "description": "Amount of base token to add", - "type": "number" - }, - "quoteTokenAmount": { - "description": "Amount of quote token to add", - "type": "number" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 - } - }, - "required": ["positionAddress", "baseTokenAmount", "quoteTokenAmount"] + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14214,42 +7533,21 @@ } } }, - "/connectors/pancakeswap/clmm/remove-liquidity": { + "/trading/clmm/close": { "post": { - "tags": ["/connector/pancakeswap"], - "description": "Remove liquidity from a Pancakeswap V3 position", + "operationId": "closeClmmPosition", + "tags": [ + "/trading/clmm" + ], + "description": "Close a CLMM position across supported connectors", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "type": "string", - "default": "bsc", - "example": "bsc" - }, - "walletAddress": { - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "positionAddress": { - "type": "string", - "description": "Position NFT token ID", - "example": "1234" - }, - "percentageToRemove": { - "type": "number", - "minimum": 0, - "maximum": 100, - "example": 50 - } - }, - "required": ["positionAddress", "percentageToRemove"] + "$ref": "#/components/schemas/ClmmCloseRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -14257,32 +7555,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ClmmClosePositionResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14290,36 +7583,21 @@ } } }, - "/connectors/pancakeswap/clmm/collect-fees": { + "/trading/clmm/create-pool": { "post": { - "tags": ["/connector/pancakeswap"], - "description": "Collect fees from a Pancakeswap V3 position", + "operationId": "createClmmPool", + "tags": [ + "/trading/clmm" + ], + "description": "Create and initialize a new CLMM pool across supported connectors (Meteora DLMM, Raydium CLMM, PancakeSwap Solana CLMM, Orca Whirlpool, Uniswap V3, PancakeSwap V3)", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "type": "string", - "default": "bsc", - "example": "bsc" - }, - "walletAddress": { - "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" - }, - "positionAddress": { - "type": "string", - "description": "Position NFT token ID", - "example": "1234" - } - }, - "required": ["positionAddress"] + "$ref": "#/components/schemas/ClmmCreatePoolRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -14327,32 +7605,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ClmmCreatePoolResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14360,80 +7633,95 @@ } } }, - "/connectors/pancakeswap/clmm/close-position": { - "post": { - "tags": ["/connector/pancakeswap"], - "description": "Close a Pancakeswap V3 position by removing all liquidity and collecting fees", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "positionAddress": { - "type": "string" - } - }, - "required": ["positionAddress"] + "/trading/amm/pool-info": { + "get": { + "operationId": "getAmmPoolInfo", + "tags": [ + "/trading/amm" + ], + "description": "Get AMM pool information from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "AMM connector" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Pool contract address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmPoolInfo" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } }, - "required": true - }, - "responses": { - "200": { + "500": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14441,31 +7729,76 @@ } } }, - "/connectors/pancakeswap-sol/clmm/pool-info": { + "/trading/amm/position-info": { "get": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Get CLMM pool information from PancakeSwap Solana", + "operationId": "getAmmPositionInfo", + "tags": [ + "/trading/amm" + ], + "description": "Get a wallet's aggregated AMM liquidity in a pool from any supported connector", "parameters": [ { "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", "type": "string" }, + "example": "meteora", "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + "name": "connector", + "required": true, + "description": "AMM connector" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN", "in": "query", "name": "poolAddress", "required": true, - "description": "PancakeSwap CLMM pool address" + "description": "Pool contract address" + }, + { + "schema": { + "default": "", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": true, + "description": "Wallet address" } ], "responses": { @@ -14474,46 +7807,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ] + "$ref": "#/components/schemas/AmmPositionInfo" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14521,31 +7835,67 @@ } } }, - "/connectors/pancakeswap-sol/clmm/position-info": { + "/trading/amm/positions-owned": { "get": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Get CLMM position information from PancakeSwap Solana", + "operationId": "listAmmPositions", + "tags": [ + "/trading/amm" + ], + "description": "List all AMM positions a wallet owns across pools. Supported only for non-fungible-LP AMMs (meteora DAMM v2). Fungible-LP AMMs (raydium, uniswap, pancakeswap) have no enumerable positions — use position-info with a specific pool address instead.", "parameters": [ { "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", "type": "string" }, + "example": "meteora", "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + "name": "connector", + "required": true, + "description": "AMM connector (only non-fungible-LP AMMs supported: meteora)" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { + "default": "", "type": "string" }, - "example": "", "in": "query", - "name": "positionAddress", + "name": "walletAddress", "required": true, - "description": "Position NFT address" + "description": "Wallet address to list positions for" } ], "responses": { @@ -14554,69 +7904,30 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] + "type": "array", + "items": { + "$ref": "#/components/schemas/AmmPositionInfo" + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14624,115 +7935,128 @@ } } }, - "/connectors/pancakeswap-sol/clmm/positions-owned": { + "/trading/amm/quote-liquidity": { "get": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Retrieve all positions owned by a user's wallet across all PancakeSwap Solana CLMM pools", + "operationId": "quoteAmmLiquidity", + "tags": [ + "/trading/amm" + ], + "description": "Quote amounts for adding liquidity to an AMM pool from any supported connector", "parameters": [ { "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", "type": "string" }, + "example": "meteora", "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" + "name": "connector", + "required": true, + "description": "AMM connector" + }, + { + "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { "type": "string" }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", "in": "query", - "name": "walletAddress", + "name": "poolAddress", "required": true, - "description": "Solana wallet address to check for positions" + "description": "Pool contract address" }, { "schema": { - "type": "string" + "format": "decimal", + "type": "number" }, - "example": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN", "in": "query", - "name": "poolAddress", - "required": false, - "description": "Optional pool address to filter positions by specific pool" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } + "name": "baseTokenAmount", + "required": true, + "description": "Amount of base token to deposit" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "in": "query", + "name": "quoteTokenAmount", + "required": true, + "description": "Amount of quote token to deposit" + }, + { + "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + }, + "example": 1, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmQuoteLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14740,118 +8064,353 @@ } } }, - "/connectors/pancakeswap-sol/clmm/quote-position": { + "/trading/amm/quote-swap": { "get": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Quote position amounts for PancakeSwap Solana CLMM (simplified)", + "operationId": "quoteAmmSwap", + "tags": [ + "/trading/amm" + ], + "description": "Get a swap quote from a single AMM pool", "parameters": [ { "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", "type": "string" }, + "example": "meteora", "in": "query", - "name": "network", + "name": "connector", "required": false, - "description": "Solana network to use" + "description": "AMM connector to price the swap against" }, { "schema": { + "default": "SOL", "type": "string" }, - "example": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN", "in": "query", - "name": "poolAddress", + "name": "baseToken", "required": true, - "description": "PancakeSwap CLMM pool address" + "description": "Symbol or address of the base token" }, { "schema": { - "type": "number" + "default": "USDC", + "type": "string" }, - "example": 150, "in": "query", - "name": "lowerPrice", + "name": "quoteToken", "required": true, - "description": "Lower price bound for the position" + "description": "Symbol or address of the quote token" }, { "schema": { + "format": "decimal", + "default": 1, "type": "number" }, - "example": 250, "in": "query", - "name": "upperPrice", + "name": "amount", "required": true, - "description": "Upper price bound for the position" + "description": "Amount of base token to trade" }, { "schema": { - "type": "number" + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" }, - "example": 0.01, "in": "query", - "name": "baseTokenAmount", - "required": false, - "description": "Amount of base token to deposit" + "name": "side", + "required": true, + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token" }, { "schema": { - "type": "number" + "type": "string" }, - "example": 2, "in": "query", - "name": "quoteTokenAmount", + "name": "poolAddress", "required": false, - "description": "Amount of quote token to deposit" + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list." }, { "schema": { + "format": "decimal", "minimum": 0, "maximum": 100, - "default": 2, "type": "number" }, - "example": 2, + "example": 1, "in": "query", "name": "slippagePct", "required": false, - "description": "Maximum acceptable slippage percentage" + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChainQuoteSwapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/amm/execute-swap": { + "post": { + "operationId": "executeAmmSwap", + "tags": [ + "/trading/amm" + ], + "description": "Execute a swap against a single AMM pool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmExecuteSwapRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChainExecuteSwapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/amm/add": { + "post": { + "operationId": "addAmmLiquidity", + "tags": [ + "/trading/amm" + ], + "description": "Add liquidity to an AMM pool from any supported connector", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmAddRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmAddLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } + } + } + }, + "/trading/amm/remove": { + "post": { + "operationId": "removeAmmLiquidity", + "tags": [ + "/trading/amm" + ], + "description": "Remove liquidity from an AMM pool from any supported connector", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmRemoveRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmRemoveLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/amm/create-pool": { + "post": { + "operationId": "createAmmPool", + "tags": [ + "/trading/amm" ], + "description": "Create and seed a new AMM pool across supported connectors (Meteora DAMM v2, Raydium CPMM, Uniswap V2, PancakeSwap V2)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmCreatePoolRequest" + } + } + } + }, "responses": { "200": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] + "$ref": "#/components/schemas/AmmCreatePoolResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14859,85 +8418,136 @@ } } }, - "/connectors/pancakeswap-sol/clmm/quote-swap": { + "/chains/{chain}/status": { "get": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Get swap quote for PancakeSwap Solana CLMM with fee and estimated price impact based on pool liquidity", + "operationId": "getChainStatus", + "tags": [ + "/chains" + ], + "description": "Get the status of a chain and network", "parameters": [ { "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], "type": "string" }, + "example": "mainnet-beta", "in": "query", "name": "network", "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN", - "in": "query", - "name": "poolAddress", - "required": false, - "description": "CLMM pool address (optional - can be looked up from tokens)" + "description": "Network to use. Defaults to the chain's configured default network." }, { "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, - "example": "SOL", - "in": "query", - "name": "baseToken", + "in": "path", + "name": "chain", "required": true, - "description": "Base token symbol or address" + "description": "Chain to operate on" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": true, - "description": "Quote token symbol or address" + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/chains/{chain}/estimate-gas": { + "get": { + "operationId": "estimateGas", + "tags": [ + "/chains" + ], + "description": "Estimate the current transaction fee on a chain", + "parameters": [ { "schema": { - "type": "number" + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" }, - "example": 0.01, + "example": "mainnet-beta", "in": "query", - "name": "amount", - "required": true, - "description": "Amount to swap" + "name": "network", + "required": false, + "description": "Network to use. Defaults to the chain's configured default network." }, { "schema": { - "enum": ["BUY", "SELL"], - "default": "SELL", + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, - "in": "query", - "name": "side", + "in": "path", + "name": "chain", "required": true, - "description": "Trade direction" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" - }, - "example": 2, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" + "description": "Chain to operate on" } ], "responses": { @@ -14946,50 +8556,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "price": { - "type": "number" - }, - "slippagePct": { - "type": "number" - }, - "minAmountOut": { - "type": "number" - }, - "maxAmountIn": { - "type": "number" - }, - "priceImpactPct": { - "type": "number" - } - }, - "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" - ] + "$ref": "#/components/schemas/EstimateGasResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14997,121 +8584,65 @@ } } }, - "/connectors/pancakeswap-sol/clmm/execute-swap": { + "/chains/{chain}/balances": { "post": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Execute a swap on PancakeSwap Solana CLMM", + "operationId": "getBalances", + "tags": [ + "/chains" + ], + "description": "Get token balances for a wallet", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "poolAddress": { - "description": "CLMM pool address (optional)", - "type": "string", - "example": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN" - }, - "baseToken": { - "description": "Base token symbol or address", - "type": "string", - "example": "SOL" - }, - "quoteToken": { - "description": "Quote token symbol or address", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount to swap", - "type": "number", - "example": 0.01 - }, - "side": { - "description": "Trade direction", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string", - "example": "SELL" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["baseToken", "quoteToken", "amount", "side"] + "$ref": "#/components/schemas/BalanceRequest" } } - }, - "required": true + } }, + "parameters": [ + { + "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true, + "description": "Chain to operate on" + } + ], "responses": { "200": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "baseTokenBalanceChange": { - "type": "number" - }, - "quoteTokenBalanceChange": { - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/BalanceResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15119,111 +8650,65 @@ } } }, - "/connectors/pancakeswap-sol/clmm/open-position": { + "/chains/{chain}/poll": { "post": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Open a new PancakeSwap Solana CLMM position with Token2022 NFT", + "operationId": "pollTransaction", + "tags": [ + "/chains" + ], + "description": "Poll a transaction by signature/hash", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "poolAddress": { - "description": "PancakeSwap CLMM pool address", - "type": "string", - "example": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN" - }, - "lowerPrice": { - "description": "Lower price bound for the position", - "type": "number", - "example": 150 - }, - "upperPrice": { - "description": "Upper price bound for the position", - "type": "number", - "example": 250 - }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["poolAddress", "lowerPrice", "upperPrice"] + "$ref": "#/components/schemas/PollRequest" } } - }, - "required": true + } }, + "parameters": [ + { + "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true, + "description": "Chain to operate on" + } + ], "responses": { "200": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/PollResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15231,89 +8716,65 @@ } } }, - "/connectors/pancakeswap-sol/clmm/add-liquidity": { + "/chains/{chain}/wrap": { "post": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Add liquidity to an existing PancakeSwap Solana CLMM position", + "operationId": "wrapNativeToken", + "tags": [ + "/chains" + ], + "description": "Wrap native token into its wrapped form (SOL to WSOL, ETH to WETH, ...)", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "" - }, - "baseTokenAmount": { - "description": "Amount of base token to add", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "description": "Amount of quote token to add", - "type": "number", - "example": 2 - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["positionAddress", "baseTokenAmount", "quoteTokenAmount"] + "$ref": "#/components/schemas/WrapRequest" } } - }, - "required": true + } }, + "parameters": [ + { + "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true, + "description": "Chain to operate on" + } + ], "responses": { "200": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ChainWrapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15321,78 +8782,65 @@ } } }, - "/connectors/pancakeswap-sol/clmm/remove-liquidity": { + "/chains/{chain}/unwrap": { "post": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Remove liquidity from a PancakeSwap Solana CLMM position", + "operationId": "unwrapNativeToken", + "tags": [ + "/chains" + ], + "description": "Unwrap a wrapped native token back into the native token", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position NFT address to remove liquidity from", - "type": "string", - "example": "" - }, - "percentageToRemove": { - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "type": "number", - "example": 100 - } - }, - "required": ["positionAddress", "percentageToRemove"] + "$ref": "#/components/schemas/UnwrapRequest" } } - }, - "required": true + } }, + "parameters": [ + { + "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true, + "description": "Chain to operate on" + } + ], "responses": { "200": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ChainWrapResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15400,38 +8848,21 @@ } } }, - "/connectors/pancakeswap-sol/clmm/collect-fees": { + "/chains/ethereum/allowances": { "post": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Collect accumulated fees from a PancakeSwap Solana CLMM position (removes 1% liquidity)", + "operationId": "getAllowances", + "tags": [ + "/chains" + ], + "description": "Get token allowances", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "" - } - }, - "required": ["positionAddress"] + "$ref": "#/components/schemas/AllowancesRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -15439,32 +8870,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/AllowancesResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15472,38 +8898,21 @@ } } }, - "/connectors/pancakeswap-sol/clmm/close-position": { + "/chains/ethereum/approve": { "post": { - "tags": ["/connector/pancakeswap-sol"], - "description": "Close a PancakeSwap Solana CLMM position and remove all liquidity and fees if present", + "operationId": "approveToken", + "tags": [ + "/chains" + ], + "description": "Approve token spending", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "positionAddress": { - "description": "Position NFT address to close", - "type": "string", - "example": "" - } - }, - "required": ["positionAddress"] + "$ref": "#/components/schemas/ApproveRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -15511,48 +8920,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": ["signature", "status"] + "$ref": "#/components/schemas/ApproveResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15584,52 +8972,20 @@ "description": "Pool management endpoints" }, { - "name": "/trading/swap", - "description": "Unified cross-chain swap endpoints" - }, - { - "name": "/trading/clmm", - "description": "Unified cross-chain CLMM (Concentrated Liquidity) endpoints" - }, - { - "name": "/chain/solana", - "description": "Solana and SVM-based chain endpoints" - }, - { - "name": "/chain/ethereum", - "description": "Ethereum and EVM-based chain endpoints" - }, - { - "name": "/connector/jupiter", - "description": "Jupiter connector endpoints" - }, - { - "name": "/connector/meteora", - "description": "Meteora connector endpoints" - }, - { - "name": "/connector/orca", - "description": "Orca connector endpoints" - }, - { - "name": "/connector/raydium", - "description": "Raydium connector endpoints" - }, - { - "name": "/connector/uniswap", - "description": "Uniswap connector endpoints" + "name": "/chains", + "description": "Chain endpoints, parameterized by chain" }, { - "name": "/connector/0x", - "description": "0x connector endpoints" + "name": "/trading/router", + "description": "Swaps routed across pools by a router connector" }, { - "name": "/connector/pancakeswap-sol", - "description": "PancakeSwap Solana connector endpoints" + "name": "/trading/clmm", + "description": "Concentrated-liquidity pools: swaps, positions, and pool management" }, { - "name": "/connector/pancakeswap", - "description": "PancakeSwap EVM connector endpoints" + "name": "/trading/amm", + "description": "Constant-product pools: swaps, liquidity, and pool management" } ] } diff --git a/package.json b/package.json index 005c7718b4..10c188f704 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:scripts": "GATEWAY_TEST_MODE=dev jest --runInBand ./test-scripts/*.test.ts", "typecheck": "tsc --noEmit", "wallet:create": "npx ts-node scripts/create-wallet.ts", - "generate:openapi": "curl http://localhost:15888/docs/json -o openapi.json && echo 'OpenAPI spec saved to openapi.json'", + "generate:openapi": "npx ts-node scripts/generate-openapi.ts", "rebuild-bigint": "cd node_modules/bigint-buffer && pnpm run rebuild", "prepare": "pnpm husky" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a03794f009..9c9704891d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,6 @@ overrides: axios: ^1.8.4 importers: - .: dependencies: '@coral-xyz/anchor': @@ -382,155 +381,193 @@ importers: version: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) packages: - '@adraffy/ens-normalize@1.11.0': - resolution: {integrity: sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg==} + resolution: + { integrity: sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg== } '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== } + engines: { node: '>=6.0.0' } '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} + resolution: + { integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg== } + engines: { node: '>=16.0.0' } '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + resolution: + { integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag== } '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + resolution: + { integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg== } '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + resolution: + { integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw== } '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} + resolution: + { integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== } + engines: { node: '>=16.0.0' } '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + resolution: + { integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg== } '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + resolution: + { integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== } '@aws-sdk/client-s3@3.806.0': - resolution: {integrity: sha512-kQaBBBxEBU/IJ2wKG+LL2BK+uvBwpdvOA9jy1WhW+U2/DIMwMrjVs7M/ZvTlmVOJwhZaONcJbgQqsN4Yirjj4g==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-kQaBBBxEBU/IJ2wKG+LL2BK+uvBwpdvOA9jy1WhW+U2/DIMwMrjVs7M/ZvTlmVOJwhZaONcJbgQqsN4Yirjj4g== } + engines: { node: '>=18.0.0' } '@aws-sdk/client-sso@3.806.0': - resolution: {integrity: sha512-X0p/9/u9e6b22rlQqKucdtjdqmjSNB4c/8zDEoD5MvgYAAbMF9HNE0ST2xaA/WsJ7uE0jFfhPY2/00pslL1DqQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-X0p/9/u9e6b22rlQqKucdtjdqmjSNB4c/8zDEoD5MvgYAAbMF9HNE0ST2xaA/WsJ7uE0jFfhPY2/00pslL1DqQ== } + engines: { node: '>=18.0.0' } '@aws-sdk/core@3.806.0': - resolution: {integrity: sha512-HJRINPncdjPK0iL3f6cBpqCMaxVwq2oDbRCzOx04tsLZ0tNgRACBfT3d/zNVRvMt6fnOVKXoN1LAtQaw50pjEA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-HJRINPncdjPK0iL3f6cBpqCMaxVwq2oDbRCzOx04tsLZ0tNgRACBfT3d/zNVRvMt6fnOVKXoN1LAtQaw50pjEA== } + engines: { node: '>=18.0.0' } '@aws-sdk/credential-provider-env@3.806.0': - resolution: {integrity: sha512-nbPwmZn0kt6Q1XI2FaJWP6AhF9tro4cO5HlmZQx8NU+B0H1y9WMo659Q5zLLY46BXgoQVIJEsPSZpcZk27O4aw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-nbPwmZn0kt6Q1XI2FaJWP6AhF9tro4cO5HlmZQx8NU+B0H1y9WMo659Q5zLLY46BXgoQVIJEsPSZpcZk27O4aw== } + engines: { node: '>=18.0.0' } '@aws-sdk/credential-provider-http@3.806.0': - resolution: {integrity: sha512-e/gB2iJQQ4ZpecOVpEFhEvjGwuTqNCzhVaVsFYVc49FPfR1seuN7qBGYe1MO7mouGDQFInzJgcNup0DnYUrLiw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-e/gB2iJQQ4ZpecOVpEFhEvjGwuTqNCzhVaVsFYVc49FPfR1seuN7qBGYe1MO7mouGDQFInzJgcNup0DnYUrLiw== } + engines: { node: '>=18.0.0' } '@aws-sdk/credential-provider-ini@3.806.0': - resolution: {integrity: sha512-FogfbuYSEZgFxbNy0QcsBZHHe5mSv5HV3+JyB5n0kCyjOISCVCZD7gwxKdXjt8O1hXq5k5SOdQvydGULlB6rew==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-FogfbuYSEZgFxbNy0QcsBZHHe5mSv5HV3+JyB5n0kCyjOISCVCZD7gwxKdXjt8O1hXq5k5SOdQvydGULlB6rew== } + engines: { node: '>=18.0.0' } '@aws-sdk/credential-provider-node@3.806.0': - resolution: {integrity: sha512-fZX8xP2Kf0k70kDTog/87fh/M+CV0E2yujSw1cUBJhDSwDX3RlUahiJk7TpB/KGw6hEFESMd6+7kq3UzYuw3rg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-fZX8xP2Kf0k70kDTog/87fh/M+CV0E2yujSw1cUBJhDSwDX3RlUahiJk7TpB/KGw6hEFESMd6+7kq3UzYuw3rg== } + engines: { node: '>=18.0.0' } '@aws-sdk/credential-provider-process@3.806.0': - resolution: {integrity: sha512-8Y8GYEw/1e5IZRDQL02H6nsTDcRWid/afRMeWg+93oLQmbHcTtdm48tjis+7Xwqy+XazhMDmkbUht11QPTDJcQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-8Y8GYEw/1e5IZRDQL02H6nsTDcRWid/afRMeWg+93oLQmbHcTtdm48tjis+7Xwqy+XazhMDmkbUht11QPTDJcQ== } + engines: { node: '>=18.0.0' } '@aws-sdk/credential-provider-sso@3.806.0': - resolution: {integrity: sha512-hT9OBwCxWMPBydNhXm2gdNNzx5AJNheS9RglwDDvXWzQ9qDuRztjuMBilMSUMb0HF9K4IqQjYzGqczMuktz4qQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-hT9OBwCxWMPBydNhXm2gdNNzx5AJNheS9RglwDDvXWzQ9qDuRztjuMBilMSUMb0HF9K4IqQjYzGqczMuktz4qQ== } + engines: { node: '>=18.0.0' } '@aws-sdk/credential-provider-web-identity@3.806.0': - resolution: {integrity: sha512-XxaSY9Zd3D4ClUGENYMvi52ac5FuJPPAsvRtEfyrSdEpf6QufbMpnexWBZMYRF31h/VutgqtJwosGgNytpxMEg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-XxaSY9Zd3D4ClUGENYMvi52ac5FuJPPAsvRtEfyrSdEpf6QufbMpnexWBZMYRF31h/VutgqtJwosGgNytpxMEg== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-bucket-endpoint@3.806.0': - resolution: {integrity: sha512-ACjuyKJw9OZl8z8HzPEaqn1o7ElVW94mowyoZvyUIDouwAPGqPGJbJ5V35qx1oDTFSAJX+N3O3AO6RyFc8nUhw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-ACjuyKJw9OZl8z8HzPEaqn1o7ElVW94mowyoZvyUIDouwAPGqPGJbJ5V35qx1oDTFSAJX+N3O3AO6RyFc8nUhw== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-expect-continue@3.804.0': - resolution: {integrity: sha512-YW1hySBolALMII6C8y7Z0CRG2UX1dGJjLEBNFeefhO/xP7ZuE1dvnmfJGaEuBMnvc3wkRS63VZ3aqX6sevM1CA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-YW1hySBolALMII6C8y7Z0CRG2UX1dGJjLEBNFeefhO/xP7ZuE1dvnmfJGaEuBMnvc3wkRS63VZ3aqX6sevM1CA== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-flexible-checksums@3.806.0': - resolution: {integrity: sha512-YEmuU2Nr/+blhi70gS38fnCe2IoL6OVVZXMp4MbzqZRUqeBbnxZhHQrd5YOiboJz7iq+g98xwFebHY167iejcg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-YEmuU2Nr/+blhi70gS38fnCe2IoL6OVVZXMp4MbzqZRUqeBbnxZhHQrd5YOiboJz7iq+g98xwFebHY167iejcg== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-host-header@3.804.0': - resolution: {integrity: sha512-bum1hLVBrn2lJCi423Z2fMUYtsbkGI2s4N+2RI2WSjvbaVyMSv/WcejIrjkqiiMR+2Y7m5exgoKeg4/TODLDPQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-bum1hLVBrn2lJCi423Z2fMUYtsbkGI2s4N+2RI2WSjvbaVyMSv/WcejIrjkqiiMR+2Y7m5exgoKeg4/TODLDPQ== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-location-constraint@3.804.0': - resolution: {integrity: sha512-AMtKnllIWKgoo7hiJfphLYotEwTERfjVMO2+cKAncz9w1g+bnYhHxiVhJJoR94y047c06X4PU5MsTxvdQ73Znw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-AMtKnllIWKgoo7hiJfphLYotEwTERfjVMO2+cKAncz9w1g+bnYhHxiVhJJoR94y047c06X4PU5MsTxvdQ73Znw== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-logger@3.804.0': - resolution: {integrity: sha512-w/qLwL3iq0KOPQNat0Kb7sKndl9BtceigINwBU7SpkYWX9L/Lem6f8NPEKrC9Tl4wDBht3Yztub4oRTy/horJA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-w/qLwL3iq0KOPQNat0Kb7sKndl9BtceigINwBU7SpkYWX9L/Lem6f8NPEKrC9Tl4wDBht3Yztub4oRTy/horJA== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-recursion-detection@3.804.0': - resolution: {integrity: sha512-zqHOrvLRdsUdN/ehYfZ9Tf8svhbiLLz5VaWUz22YndFv6m9qaAcijkpAOlKexsv3nLBMJdSdJ6GUTAeIy3BZzw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-zqHOrvLRdsUdN/ehYfZ9Tf8svhbiLLz5VaWUz22YndFv6m9qaAcijkpAOlKexsv3nLBMJdSdJ6GUTAeIy3BZzw== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-sdk-s3@3.806.0': - resolution: {integrity: sha512-K1ssdovHH/kPN9EUS1LznwzoL+r89Cx8qAkp0K8MqdCQuBjZ0KRnjvo9nx69Vg5d/rg01VYTxomFUPXfcPtVXw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-K1ssdovHH/kPN9EUS1LznwzoL+r89Cx8qAkp0K8MqdCQuBjZ0KRnjvo9nx69Vg5d/rg01VYTxomFUPXfcPtVXw== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-ssec@3.804.0': - resolution: {integrity: sha512-Tk8jK0gOIUBvEPTz/wwSlP1V70zVQ3QYqsLPAjQRMO6zfOK9ax31dln3MgKvFDJxBydS2tS3wsn53v+brxDxTA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Tk8jK0gOIUBvEPTz/wwSlP1V70zVQ3QYqsLPAjQRMO6zfOK9ax31dln3MgKvFDJxBydS2tS3wsn53v+brxDxTA== } + engines: { node: '>=18.0.0' } '@aws-sdk/middleware-user-agent@3.806.0': - resolution: {integrity: sha512-XoIromVffgXnc+/mjlR2EVzQVIei3bPVtafIZNsHuEmUvIWJXiWsa2eJpt3BUqa0HF9YPknK7ommNEhqRb8ucg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-XoIromVffgXnc+/mjlR2EVzQVIei3bPVtafIZNsHuEmUvIWJXiWsa2eJpt3BUqa0HF9YPknK7ommNEhqRb8ucg== } + engines: { node: '>=18.0.0' } '@aws-sdk/nested-clients@3.806.0': - resolution: {integrity: sha512-ua2gzpfQ9MF8Rny+tOAivowOWWvqEusez2rdcQK8jdBjA1ANd/0xzToSZjZh0ziN8Kl8jOhNnHbQJ0v6dT6+hg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-ua2gzpfQ9MF8Rny+tOAivowOWWvqEusez2rdcQK8jdBjA1ANd/0xzToSZjZh0ziN8Kl8jOhNnHbQJ0v6dT6+hg== } + engines: { node: '>=18.0.0' } '@aws-sdk/region-config-resolver@3.806.0': - resolution: {integrity: sha512-cuv5pX55JOlzKC/iLsB5nZ9eUyVgncim3VhhWHZA/KYPh7rLMjOEfZ+xyaE9uLJXGmzOJboFH7+YdTRdIcOgrg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-cuv5pX55JOlzKC/iLsB5nZ9eUyVgncim3VhhWHZA/KYPh7rLMjOEfZ+xyaE9uLJXGmzOJboFH7+YdTRdIcOgrg== } + engines: { node: '>=18.0.0' } '@aws-sdk/signature-v4-multi-region@3.806.0': - resolution: {integrity: sha512-IrbEnpKvG8d9rUWAvsF28g8qBlQ02FaOxn4cGXtTs0b0BGMK1M+cGQrYjJ7Ak08kIXDxBqsdIlZGsKYr+Ds9+w==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-IrbEnpKvG8d9rUWAvsF28g8qBlQ02FaOxn4cGXtTs0b0BGMK1M+cGQrYjJ7Ak08kIXDxBqsdIlZGsKYr+Ds9+w== } + engines: { node: '>=18.0.0' } '@aws-sdk/token-providers@3.806.0': - resolution: {integrity: sha512-I6SxcsvV7yinJZmPgGullFHS0tsTKa7K3jEc5dmyCz8X+kZPfsWNffZmtmnCvWXPqMXWBvK6hVaxwomx79yeHA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-I6SxcsvV7yinJZmPgGullFHS0tsTKa7K3jEc5dmyCz8X+kZPfsWNffZmtmnCvWXPqMXWBvK6hVaxwomx79yeHA== } + engines: { node: '>=18.0.0' } '@aws-sdk/types@3.804.0': - resolution: {integrity: sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg== } + engines: { node: '>=18.0.0' } '@aws-sdk/util-arn-parser@3.804.0': - resolution: {integrity: sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ== } + engines: { node: '>=18.0.0' } '@aws-sdk/util-endpoints@3.806.0': - resolution: {integrity: sha512-3YRRgZ+qFuWDdm5uAbxKsr65UAil4KkrFKua9f4m7Be3v24ETiFOOqhanFUIk9/WOtvzF7oFEiDjYKDGlwV2xg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-3YRRgZ+qFuWDdm5uAbxKsr65UAil4KkrFKua9f4m7Be3v24ETiFOOqhanFUIk9/WOtvzF7oFEiDjYKDGlwV2xg== } + engines: { node: '>=18.0.0' } '@aws-sdk/util-locate-window@3.804.0': - resolution: {integrity: sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A== } + engines: { node: '>=18.0.0' } '@aws-sdk/util-user-agent-browser@3.804.0': - resolution: {integrity: sha512-KfW6T6nQHHM/vZBBdGn6fMyG/MgX5lq82TDdX4HRQRRuHKLgBWGpKXqqvBwqIaCdXwWHgDrg2VQups6GqOWW2A==} + resolution: + { integrity: sha512-KfW6T6nQHHM/vZBBdGn6fMyG/MgX5lq82TDdX4HRQRRuHKLgBWGpKXqqvBwqIaCdXwWHgDrg2VQups6GqOWW2A== } '@aws-sdk/util-user-agent-node@3.806.0': - resolution: {integrity: sha512-Az2e4/gmPZ4BpB7QRj7U76I+fctXhNcxlcgsaHnMhvt+R30nvzM2EhsyBUvsWl8+r9bnLeYt9BpvEZeq2ANDzA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Az2e4/gmPZ4BpB7QRj7U76I+fctXhNcxlcgsaHnMhvt+R30nvzM2EhsyBUvsWl8+r9bnLeYt9BpvEZeq2ANDzA== } + engines: { node: '>=18.0.0' } peerDependencies: aws-crt: '>=1.0.0' peerDependenciesMeta: @@ -538,625 +575,772 @@ packages: optional: true '@aws-sdk/xml-builder@3.804.0': - resolution: {integrity: sha512-JbGWp36IG9dgxtvC6+YXwt5WDZYfuamWFtVfK6fQpnmL96dx+GUPOXPKRWdw67WLKf2comHY28iX2d3z35I53Q==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-JbGWp36IG9dgxtvC6+YXwt5WDZYfuamWFtVfK6fQpnmL96dx+GUPOXPKRWdw67WLKf2comHY28iX2d3z35I53Q== } + engines: { node: '>=18.0.0' } '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== } + engines: { node: '>=6.9.0' } '@babel/compat-data@7.27.2': - resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ== } + engines: { node: '>=6.9.0' } '@babel/core@7.27.1': - resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ== } + engines: { node: '>=6.9.0' } '@babel/generator@7.27.1': - resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w== } + engines: { node: '>=6.9.0' } '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== } + engines: { node: '>=6.9.0' } '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== } + engines: { node: '>=6.9.0' } '@babel/helper-module-transforms@7.27.1': - resolution: {integrity: sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0 '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== } + engines: { node: '>=6.9.0' } '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== } + engines: { node: '>=6.9.0' } '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== } + engines: { node: '>=6.9.0' } '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== } + engines: { node: '>=6.9.0' } '@babel/helpers@7.27.1': - resolution: {integrity: sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ== } + engines: { node: '>=6.9.0' } '@babel/parser@7.27.2': - resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw== } + engines: { node: '>=6.0.0' } hasBin: true '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + resolution: + { integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + resolution: + { integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + resolution: + { integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + resolution: + { integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + resolution: + { integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + resolution: + { integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + resolution: + { integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + resolution: + { integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + resolution: + { integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + resolution: + { integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + resolution: + { integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0-0 '@babel/runtime@7.27.1': - resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog== } + engines: { node: '>=6.9.0' } '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== } + engines: { node: '>=6.9.0' } '@babel/traverse@7.27.1': - resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg== } + engines: { node: '>=6.9.0' } '@babel/types@7.27.1': - resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q== } + engines: { node: '>=6.9.0' } '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + resolution: + { integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== } '@bundlr-network/client@0.7.17': - resolution: {integrity: sha512-1qTDrwgmgeh0pO24JbeGt2W8GlpWYkVnQ8AhEZ02Lm00J7RALSyma3C5pNlKuvAQBczL1r9KhLr9KHK1og3J0g==} + resolution: + { integrity: sha512-1qTDrwgmgeh0pO24JbeGt2W8GlpWYkVnQ8AhEZ02Lm00J7RALSyma3C5pNlKuvAQBczL1r9KhLr9KHK1og3J0g== } deprecated: Bundlr is now Irys - please switch to @irys/sdk - this package will remain compatible with Irys for the foreseeable future. hasBin: true '@colors/colors@1.6.0': - resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} - engines: {node: '>=0.1.90'} + resolution: + { integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== } + engines: { node: '>=0.1.90' } '@coral-xyz/anchor-errors@0.30.1': - resolution: {integrity: sha512-9Mkradf5yS5xiLWrl9WrpjqOrAV+/W2RQHDlbnAZBivoGpOs1ECjoDCkVk4aRG8ZdiFiB8zQEVlxf+8fKkmSfQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-9Mkradf5yS5xiLWrl9WrpjqOrAV+/W2RQHDlbnAZBivoGpOs1ECjoDCkVk4aRG8ZdiFiB8zQEVlxf+8fKkmSfQ== } + engines: { node: '>=10' } '@coral-xyz/anchor-errors@0.31.1': - resolution: {integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ== } + engines: { node: '>=10' } '@coral-xyz/anchor@0.30.1': - resolution: {integrity: sha512-gDXFoF5oHgpriXAaLpxyWBHdCs8Awgf/gLHIo6crv7Aqm937CNdY+x+6hoj7QR5vaJV7MxWSQ0NGFzL3kPbWEQ==} - engines: {node: '>=11'} + resolution: + { integrity: sha512-gDXFoF5oHgpriXAaLpxyWBHdCs8Awgf/gLHIo6crv7Aqm937CNdY+x+6hoj7QR5vaJV7MxWSQ0NGFzL3kPbWEQ== } + engines: { node: '>=11' } '@coral-xyz/anchor@0.31.0': - resolution: {integrity: sha512-Yb1NwP1s4cWhAw7wL7vOLHSWWw3cD5D9pRCVSeJpdqPaI+w7sfRLScnVJL6ViYMZynB7nAG/5HcUPKUnY0L9rw==} - engines: {node: '>=17'} + resolution: + { integrity: sha512-Yb1NwP1s4cWhAw7wL7vOLHSWWw3cD5D9pRCVSeJpdqPaI+w7sfRLScnVJL6ViYMZynB7nAG/5HcUPKUnY0L9rw== } + engines: { node: '>=17' } '@coral-xyz/borsh@0.30.1': - resolution: {integrity: sha512-aaxswpPrCFKl8vZTbxLssA2RvwX2zmKLlRCIktJOwW+VpVwYtXRtlWiIP+c2pPRKneiTiWCN2GEMSH9j1zTlWQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-aaxswpPrCFKl8vZTbxLssA2RvwX2zmKLlRCIktJOwW+VpVwYtXRtlWiIP+c2pPRKneiTiWCN2GEMSH9j1zTlWQ== } + engines: { node: '>=10' } peerDependencies: '@solana/web3.js': ^1.68.0 '@coral-xyz/borsh@0.31.0': - resolution: {integrity: sha512-DwdQ5fuj+rGQCTKRnxnW1W2lvcpBaFc9m9M1TcGGlm+bwCcggmDgbLKLgF+LjIrKnc7Nd+bCACx5RA9YTK2I4Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-DwdQ5fuj+rGQCTKRnxnW1W2lvcpBaFc9m9M1TcGGlm+bwCcggmDgbLKLgF+LjIrKnc7Nd+bCACx5RA9YTK2I4Q== } + engines: { node: '>=10' } peerDependencies: '@solana/web3.js': ^1.69.0 '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== } + engines: { node: '>=12' } '@dabh/diagnostics@2.0.3': - resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + resolution: + { integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== } '@emnapi/core@1.4.3': - resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} + resolution: + { integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g== } '@emnapi/runtime@1.4.3': - resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + resolution: + { integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ== } '@emnapi/wasi-threads@1.0.2': - resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==} + resolution: + { integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA== } '@esbuild/aix-ppc64@0.25.5': - resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA== } + engines: { node: '>=18' } cpu: [ppc64] os: [aix] '@esbuild/android-arm64@0.25.5': - resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg== } + engines: { node: '>=18' } cpu: [arm64] os: [android] '@esbuild/android-arm@0.25.5': - resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA== } + engines: { node: '>=18' } cpu: [arm] os: [android] '@esbuild/android-x64@0.25.5': - resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw== } + engines: { node: '>=18' } cpu: [x64] os: [android] '@esbuild/darwin-arm64@0.25.5': - resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ== } + engines: { node: '>=18' } cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.25.5': - resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ== } + engines: { node: '>=18' } cpu: [x64] os: [darwin] '@esbuild/freebsd-arm64@0.25.5': - resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw== } + engines: { node: '>=18' } cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.25.5': - resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw== } + engines: { node: '>=18' } cpu: [x64] os: [freebsd] '@esbuild/linux-arm64@0.25.5': - resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg== } + engines: { node: '>=18' } cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.25.5': - resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw== } + engines: { node: '>=18' } cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.25.5': - resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA== } + engines: { node: '>=18' } cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.25.5': - resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg== } + engines: { node: '>=18' } cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.25.5': - resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg== } + engines: { node: '>=18' } cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.25.5': - resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ== } + engines: { node: '>=18' } cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.25.5': - resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA== } + engines: { node: '>=18' } cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.25.5': - resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ== } + engines: { node: '>=18' } cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.25.5': - resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw== } + engines: { node: '>=18' } cpu: [x64] os: [linux] '@esbuild/netbsd-arm64@0.25.5': - resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw== } + engines: { node: '>=18' } cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.25.5': - resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ== } + engines: { node: '>=18' } cpu: [x64] os: [netbsd] '@esbuild/openbsd-arm64@0.25.5': - resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw== } + engines: { node: '>=18' } cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.25.5': - resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg== } + engines: { node: '>=18' } cpu: [x64] os: [openbsd] '@esbuild/sunos-x64@0.25.5': - resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA== } + engines: { node: '>=18' } cpu: [x64] os: [sunos] '@esbuild/win32-arm64@0.25.5': - resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw== } + engines: { node: '>=18' } cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.25.5': - resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ== } + engines: { node: '>=18' } cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.25.5': - resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g== } + engines: { node: '>=18' } cpu: [x64] os: [win32] '@eslint-community/eslint-utils@4.7.0': - resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== } + 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==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + resolution: + { integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } '@eth-optimism/contracts@0.6.0': - resolution: {integrity: sha512-vQ04wfG9kMf1Fwy3FEMqH2QZbgS0gldKhcBeBUPfO8zu68L61VI97UDXmsMQXzTsEAxK8HnokW3/gosl4/NW3w==} + resolution: + { integrity: sha512-vQ04wfG9kMf1Fwy3FEMqH2QZbgS0gldKhcBeBUPfO8zu68L61VI97UDXmsMQXzTsEAxK8HnokW3/gosl4/NW3w== } peerDependencies: ethers: ^5 '@eth-optimism/core-utils@0.12.0': - resolution: {integrity: sha512-qW+7LZYCz7i8dRa7SRlUKIo1VBU8lvN0HeXCxJR+z+xtMzMQpPds20XJNCMclszxYQHkXY00fOT6GvFw9ZL6nw==} + resolution: + { integrity: sha512-qW+7LZYCz7i8dRa7SRlUKIo1VBU8lvN0HeXCxJR+z+xtMzMQpPds20XJNCMclszxYQHkXY00fOT6GvFw9ZL6nw== } '@eth-optimism/core-utils@0.13.2': - resolution: {integrity: sha512-u7TOKm1RxH1V5zw7dHmfy91bOuEAZU68LT/9vJPkuWEjaTl+BgvPDRDTurjzclHzN0GbWdcpOqPZg4ftjkJGaw==} + resolution: + { integrity: sha512-u7TOKm1RxH1V5zw7dHmfy91bOuEAZU68LT/9vJPkuWEjaTl+BgvPDRDTurjzclHzN0GbWdcpOqPZg4ftjkJGaw== } '@eth-optimism/sdk@3.3.3': - resolution: {integrity: sha512-I8xjchsZL6L66N/0Q14QvGZpsIiVfpuXBu+OX4HB3HXGvF7NQxXSRfOXzrQKj3ikhoJUpASsR4gL/yQsH+Vh3Q==} + resolution: + { integrity: sha512-I8xjchsZL6L66N/0Q14QvGZpsIiVfpuXBu+OX4HB3HXGvF7NQxXSRfOXzrQKj3ikhoJUpASsR4gL/yQsH+Vh3Q== } peerDependencies: ethers: ^5 '@ethereumjs/rlp@5.0.2': - resolution: {integrity: sha512-DziebCdg4JpGlEqEdGgXmjqcFoJi+JGulUXwEjsZGAscAQ7MyD/7LE/GVCP29vEQxKc7AAwjT3A2ywHp2xfoCA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-DziebCdg4JpGlEqEdGgXmjqcFoJi+JGulUXwEjsZGAscAQ7MyD/7LE/GVCP29vEQxKc7AAwjT3A2ywHp2xfoCA== } + engines: { node: '>=18' } hasBin: true '@ethereumjs/util@9.1.0': - resolution: {integrity: sha512-XBEKsYqLGXLah9PNJbgdkigthkG7TAGvlD/sH12beMXEyHDyigfcbdvHhmLyDWgDyOJn4QwiQUaF7yeuhnjdog==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-XBEKsYqLGXLah9PNJbgdkigthkG7TAGvlD/sH12beMXEyHDyigfcbdvHhmLyDWgDyOJn4QwiQUaF7yeuhnjdog== } + engines: { node: '>=18' } '@ethersproject/abi@5.8.0': - resolution: {integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==} + resolution: + { integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q== } '@ethersproject/abstract-provider@5.7.0': - resolution: {integrity: sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==} + resolution: + { integrity: sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw== } '@ethersproject/abstract-provider@5.8.0': - resolution: {integrity: sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==} + resolution: + { integrity: sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg== } '@ethersproject/abstract-signer@5.8.0': - resolution: {integrity: sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==} + resolution: + { integrity: sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA== } '@ethersproject/address@5.7.0': - resolution: {integrity: sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==} + resolution: + { integrity: sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA== } '@ethersproject/address@5.8.0': - resolution: {integrity: sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==} + resolution: + { integrity: sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA== } '@ethersproject/base64@5.8.0': - resolution: {integrity: sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==} + resolution: + { integrity: sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ== } '@ethersproject/basex@5.8.0': - resolution: {integrity: sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q==} + resolution: + { integrity: sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q== } '@ethersproject/bignumber@5.8.0': - resolution: {integrity: sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==} + resolution: + { integrity: sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA== } '@ethersproject/bytes@5.8.0': - resolution: {integrity: sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==} + resolution: + { integrity: sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A== } '@ethersproject/constants@5.8.0': - resolution: {integrity: sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==} + resolution: + { integrity: sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg== } '@ethersproject/contracts@5.7.0': - resolution: {integrity: sha512-5GJbzEU3X+d33CdfPhcyS+z8MzsTrBGk/sc+G+59+tPa9yFkl6HQ9D6L0QMgNTA9q8dT0XKxxkyp883XsQvbbg==} + resolution: + { integrity: sha512-5GJbzEU3X+d33CdfPhcyS+z8MzsTrBGk/sc+G+59+tPa9yFkl6HQ9D6L0QMgNTA9q8dT0XKxxkyp883XsQvbbg== } '@ethersproject/contracts@5.8.0': - resolution: {integrity: sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==} + resolution: + { integrity: sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ== } '@ethersproject/hash@5.8.0': - resolution: {integrity: sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==} + resolution: + { integrity: sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA== } '@ethersproject/hdnode@5.8.0': - resolution: {integrity: sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA==} + resolution: + { integrity: sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA== } '@ethersproject/json-wallets@5.8.0': - resolution: {integrity: sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w==} + resolution: + { integrity: sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w== } '@ethersproject/keccak256@5.7.0': - resolution: {integrity: sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==} + resolution: + { integrity: sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg== } '@ethersproject/keccak256@5.8.0': - resolution: {integrity: sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==} + resolution: + { integrity: sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng== } '@ethersproject/logger@5.8.0': - resolution: {integrity: sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==} + resolution: + { integrity: sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA== } '@ethersproject/networks@5.7.0': - resolution: {integrity: sha512-MG6oHSQHd4ebvJrleEQQ4HhVu8Ichr0RDYEfHzsVAVjHNM+w36x9wp9r+hf1JstMXtseXDtkiVoARAG6M959AA==} + resolution: + { integrity: sha512-MG6oHSQHd4ebvJrleEQQ4HhVu8Ichr0RDYEfHzsVAVjHNM+w36x9wp9r+hf1JstMXtseXDtkiVoARAG6M959AA== } '@ethersproject/networks@5.8.0': - resolution: {integrity: sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==} + resolution: + { integrity: sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg== } '@ethersproject/pbkdf2@5.8.0': - resolution: {integrity: sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg==} + resolution: + { integrity: sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg== } '@ethersproject/properties@5.8.0': - resolution: {integrity: sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==} + resolution: + { integrity: sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw== } '@ethersproject/providers@5.7.0': - resolution: {integrity: sha512-+TTrrINMzZ0aXtlwO/95uhAggKm4USLm1PbeCBR/3XZ7+Oey+3pMyddzZEyRhizHpy1HXV0FRWRMI1O3EGYibA==} + resolution: + { integrity: sha512-+TTrrINMzZ0aXtlwO/95uhAggKm4USLm1PbeCBR/3XZ7+Oey+3pMyddzZEyRhizHpy1HXV0FRWRMI1O3EGYibA== } '@ethersproject/providers@5.8.0': - resolution: {integrity: sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==} + resolution: + { integrity: sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw== } '@ethersproject/random@5.8.0': - resolution: {integrity: sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A==} + resolution: + { integrity: sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A== } '@ethersproject/rlp@5.8.0': - resolution: {integrity: sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==} + resolution: + { integrity: sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q== } '@ethersproject/sha2@5.8.0': - resolution: {integrity: sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A==} + resolution: + { integrity: sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A== } '@ethersproject/signing-key@5.8.0': - resolution: {integrity: sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==} + resolution: + { integrity: sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w== } '@ethersproject/solidity@5.7.0': - resolution: {integrity: sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==} + resolution: + { integrity: sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA== } '@ethersproject/solidity@5.8.0': - resolution: {integrity: sha512-4CxFeCgmIWamOHwYN9d+QWGxye9qQLilpgTU0XhYs1OahkclF+ewO+3V1U0mvpiuQxm5EHHmv8f7ClVII8EHsA==} + resolution: + { integrity: sha512-4CxFeCgmIWamOHwYN9d+QWGxye9qQLilpgTU0XhYs1OahkclF+ewO+3V1U0mvpiuQxm5EHHmv8f7ClVII8EHsA== } '@ethersproject/strings@5.7.0': - resolution: {integrity: sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==} + resolution: + { integrity: sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg== } '@ethersproject/strings@5.8.0': - resolution: {integrity: sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==} + resolution: + { integrity: sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg== } '@ethersproject/transactions@5.8.0': - resolution: {integrity: sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==} + resolution: + { integrity: sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg== } '@ethersproject/units@5.8.0': - resolution: {integrity: sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ==} + resolution: + { integrity: sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ== } '@ethersproject/wallet@5.8.0': - resolution: {integrity: sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA==} + resolution: + { integrity: sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA== } '@ethersproject/web@5.8.0': - resolution: {integrity: sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==} + resolution: + { integrity: sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw== } '@ethersproject/wordlists@5.8.0': - resolution: {integrity: sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg==} + resolution: + { integrity: sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg== } '@fastify/accept-negotiator@1.1.0': - resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ== } + engines: { node: '>=14' } '@fastify/ajv-compiler@3.6.0': - resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + resolution: + { integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ== } '@fastify/busboy@2.1.1': - resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== } + engines: { node: '>=14' } '@fastify/error@3.4.1': - resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + resolution: + { integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ== } '@fastify/fast-json-stringify-compiler@4.3.0': - resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + resolution: + { integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA== } '@fastify/merge-json-schemas@0.1.1': - resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + resolution: + { integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA== } '@fastify/rate-limit@9.1.0': - resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==} + resolution: + { integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA== } '@fastify/send@2.1.0': - resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} + resolution: + { integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA== } '@fastify/sensible@5.0.0': - resolution: {integrity: sha512-JgHXTnjJaiMt3sesAhLeL2KSOCwB7A77L7tUARKpSmJKczGpNPjc2NhLcitTv6YBSgducF3I5H8N46XJdFvFdA==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-JgHXTnjJaiMt3sesAhLeL2KSOCwB7A77L7tUARKpSmJKczGpNPjc2NhLcitTv6YBSgducF3I5H8N46XJdFvFdA== } + engines: { node: '>=14.0.0' } '@fastify/static@7.0.4': - resolution: {integrity: sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==} + resolution: + { integrity: sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q== } '@fastify/swagger-ui@4.2.0': - resolution: {integrity: sha512-pVutmTm49Pn98FS01E2m+eUH0WGhsHlImowWr9PXQt3rQPArSsocON8qF/8mm0dNLmilwtJZJqdsvFTnCUcapw==} + resolution: + { integrity: sha512-pVutmTm49Pn98FS01E2m+eUH0WGhsHlImowWr9PXQt3rQPArSsocON8qF/8mm0dNLmilwtJZJqdsvFTnCUcapw== } '@fastify/swagger@8.15.0': - resolution: {integrity: sha512-zy+HEEKFqPMS2sFUsQU5X0MHplhKJvWeohBwTCkBAJA/GDYGLGUWQaETEhptiqxK7Hs0fQB9B4MDb3pbwIiCwA==} + resolution: + { integrity: sha512-zy+HEEKFqPMS2sFUsQU5X0MHplhKJvWeohBwTCkBAJA/GDYGLGUWQaETEhptiqxK7Hs0fQB9B4MDb3pbwIiCwA== } '@fastify/type-provider-typebox@4.1.0': - resolution: {integrity: sha512-mXNBaBEoS6Yf4/O2ujNhu9yEZwvBC7niqRESsiftE9NP1hV6ZdV3ZsFbPf1S520BK3rTZ0F28zr+sMdIXNJlfw==} + resolution: + { integrity: sha512-mXNBaBEoS6Yf4/O2ujNhu9yEZwvBC7niqRESsiftE9NP1hV6ZdV3ZsFbPf1S520BK3rTZ0F28zr+sMdIXNJlfw== } peerDependencies: '@sinclair/typebox': '>=0.26 <=0.33' '@graphql-typed-document-node/core@3.2.0': - resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + resolution: + { integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== } peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 '@hapi/hoek@9.3.0': - resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + resolution: + { integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== } '@hapi/topo@5.1.0': - resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + resolution: + { integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== } '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} + resolution: + { integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== } + engines: { node: '>=10.10.0' } deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} + resolution: + { integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== } + engines: { node: '>=12.22' } '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + resolution: + { integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== } deprecated: Use @eslint/object-schema instead '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== } + engines: { node: '>=12' } '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== } + engines: { node: '>=8' } '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== } + engines: { node: '>=8' } '@jest/console@24.9.0': - resolution: {integrity: sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ== } + engines: { node: '>= 6' } '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -1164,28 +1348,34 @@ packages: optional: true '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -1193,366 +1383,467 @@ packages: optional: true '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/source-map@24.9.0': - resolution: {integrity: sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg== } + engines: { node: '>= 6' } '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/test-result@24.9.0': - resolution: {integrity: sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA== } + engines: { node: '>= 6' } '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jest/types@24.9.0': - resolution: {integrity: sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== } + engines: { node: '>= 6' } '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== } + engines: { node: '>=6.0.0' } '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== } + engines: { node: '>=6.0.0' } '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== } + engines: { node: '>=6.0.0' } '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + resolution: + { integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== } '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + resolution: + { integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== } '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + resolution: + { integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== } '@ledgerhq/cryptoassets-evm-signatures@13.5.9': - resolution: {integrity: sha512-S3OMEb14GspNj7wnvHwHzuMUXJSfd+EcKhhlmboIZo7c7kj0ZhHONmEQK6Ad9eVEd/TryI8YG5HMTJ+D7mtSaA==} + resolution: + { integrity: sha512-S3OMEb14GspNj7wnvHwHzuMUXJSfd+EcKhhlmboIZo7c7kj0ZhHONmEQK6Ad9eVEd/TryI8YG5HMTJ+D7mtSaA== } '@ledgerhq/devices@8.4.7': - resolution: {integrity: sha512-CljHIaPmtv93H2If1Zs1xW0pgg+M37bAoJkm6+V6Yw5S0MgFWFpLnTTNgCvHXyD8pG0+uq8TuOXUiG1oAV5AyA==} + resolution: + { integrity: sha512-CljHIaPmtv93H2If1Zs1xW0pgg+M37bAoJkm6+V6Yw5S0MgFWFpLnTTNgCvHXyD8pG0+uq8TuOXUiG1oAV5AyA== } '@ledgerhq/domain-service@1.2.35': - resolution: {integrity: sha512-7CzwI08nFIuiXz24mAT9vp0o7n5Q8uez4DYNWMvC4thyBY0ZovE1XmqAYPS/t+SgDfzJyCapD03fkyxxw/sVrQ==} + resolution: + { integrity: sha512-7CzwI08nFIuiXz24mAT9vp0o7n5Q8uez4DYNWMvC4thyBY0ZovE1XmqAYPS/t+SgDfzJyCapD03fkyxxw/sVrQ== } '@ledgerhq/errors@6.22.0': - resolution: {integrity: sha512-rXtpIOfHL62jWB7o77PNFD4EDYdcqyMeVgt7TZcmTkWT78cK+YYSUTMrNuGLhnZZZTMLWH023Wgt65OfKIdGBQ==} + resolution: + { integrity: sha512-rXtpIOfHL62jWB7o77PNFD4EDYdcqyMeVgt7TZcmTkWT78cK+YYSUTMrNuGLhnZZZTMLWH023Wgt65OfKIdGBQ== } '@ledgerhq/evm-tools@1.7.0': - resolution: {integrity: sha512-aNmkwOJ+DQNSeVNRY1/vdglmJJIeynqNUI01kpnbSL7oLpF/S7Cvwscx0wkqTWeaH80GAuxBPkVTRxRL8W/SPw==} + resolution: + { integrity: sha512-aNmkwOJ+DQNSeVNRY1/vdglmJJIeynqNUI01kpnbSL7oLpF/S7Cvwscx0wkqTWeaH80GAuxBPkVTRxRL8W/SPw== } '@ledgerhq/hw-app-eth@6.45.10': - resolution: {integrity: sha512-9NAoYg1hnX0fvhRCwTfkllP86Et6NkEMVNwzdCJEULVJGvhnEK7j5dC+xHayzZeMs7fUZ8zj3zRCUWhHCDve5w==} + resolution: + { integrity: sha512-9NAoYg1hnX0fvhRCwTfkllP86Et6NkEMVNwzdCJEULVJGvhnEK7j5dC+xHayzZeMs7fUZ8zj3zRCUWhHCDve5w== } '@ledgerhq/hw-app-solana@7.5.0': - resolution: {integrity: sha512-Fk9sFpiOBpB0OuT3MkPCDqKdbbHB1Eh+uslOBkPWxzW6rwpO285X3NEcfrMYwEAdvqX9UUL7hUoH4zkHYBQ9wg==} + resolution: + { integrity: sha512-Fk9sFpiOBpB0OuT3MkPCDqKdbbHB1Eh+uslOBkPWxzW6rwpO285X3NEcfrMYwEAdvqX9UUL7hUoH4zkHYBQ9wg== } '@ledgerhq/hw-transport-mocker@6.29.7': - resolution: {integrity: sha512-0FEEbS9XRH/Fu8G4xIZq+QbRDnsy0tO3xf2H1wDkhVv0AGHvDHSp1l7fAQZz6Q1sBmLgqjXhKvZRKzNOX4tnfQ==} + resolution: + { integrity: sha512-0FEEbS9XRH/Fu8G4xIZq+QbRDnsy0tO3xf2H1wDkhVv0AGHvDHSp1l7fAQZz6Q1sBmLgqjXhKvZRKzNOX4tnfQ== } '@ledgerhq/hw-transport-node-hid-noevents@6.30.8': - resolution: {integrity: sha512-MwJOGLvfAvoSDG1ZHxrB/7squCIaAB8dhSAKN8LpjxeMhz/99SzXOr4MwSo0B/jytMkE0gBezVB3ADkPomkNkQ==} + resolution: + { integrity: sha512-MwJOGLvfAvoSDG1ZHxrB/7squCIaAB8dhSAKN8LpjxeMhz/99SzXOr4MwSo0B/jytMkE0gBezVB3ADkPomkNkQ== } '@ledgerhq/hw-transport-node-hid-singleton@6.31.8': - resolution: {integrity: sha512-Gx4mu7siqpbXscyIc22lycWpOGTG65KxVCvbG87xrFZYWaKE1YrbhNZ93hGa96qQPb9KOTN7sQPVsXmEAiahHQ==} + resolution: + { integrity: sha512-Gx4mu7siqpbXscyIc22lycWpOGTG65KxVCvbG87xrFZYWaKE1YrbhNZ93hGa96qQPb9KOTN7sQPVsXmEAiahHQ== } '@ledgerhq/hw-transport-node-hid@6.29.8': - resolution: {integrity: sha512-lQrhdu7JyxDL1DzDfvj9HDjQd9OHkYs5yDI13NH92qBTxuVneNfuu9DiHFLpCCpm2OUghTMmw9MRUwaTwYDTLg==} + resolution: + { integrity: sha512-lQrhdu7JyxDL1DzDfvj9HDjQd9OHkYs5yDI13NH92qBTxuVneNfuu9DiHFLpCCpm2OUghTMmw9MRUwaTwYDTLg== } '@ledgerhq/hw-transport@6.31.7': - resolution: {integrity: sha512-R+QMlqoLJDPeCiqwWv85PbZ3m0hel5PwQzWwSIbyEwialqjXnG7LFQgytkgXlgMcayT0chvvLeYjuY5ZfMPY7w==} + resolution: + { integrity: sha512-R+QMlqoLJDPeCiqwWv85PbZ3m0hel5PwQzWwSIbyEwialqjXnG7LFQgytkgXlgMcayT0chvvLeYjuY5ZfMPY7w== } '@ledgerhq/live-env@2.11.0': - resolution: {integrity: sha512-7xVQfi1IdifCS50LDX7qpEkrl8fa5s4MnETM7igJcmIeIE2qIGddBnjyToh/CIbJ1benBVZBY23iJmwul4/IhQ==} + resolution: + { integrity: sha512-7xVQfi1IdifCS50LDX7qpEkrl8fa5s4MnETM7igJcmIeIE2qIGddBnjyToh/CIbJ1benBVZBY23iJmwul4/IhQ== } '@ledgerhq/logs@6.13.0': - resolution: {integrity: sha512-4+qRW2Pc8V+btL0QEmdB2X+uyx0kOWMWE1/LWsq5sZy3Q5tpi4eItJS6mB0XL3wGW59RQ+8bchNQQ1OW/va8Og==} + resolution: + { integrity: sha512-4+qRW2Pc8V+btL0QEmdB2X+uyx0kOWMWE1/LWsq5sZy3Q5tpi4eItJS6mB0XL3wGW59RQ+8bchNQQ1OW/va8Og== } '@ledgerhq/types-live@6.76.0': - resolution: {integrity: sha512-XgshTvNSie5CkudV/ebLDAR7WVEqTDX/xiqg68NZbTr+exJE5t2C4fKEzCMq8wcGVjpbqLZA+RfL/uK/9j385A==} + resolution: + { integrity: sha512-XgshTvNSie5CkudV/ebLDAR7WVEqTDX/xiqg68NZbTr+exJE5t2C4fKEzCMq8wcGVjpbqLZA+RfL/uK/9j385A== } '@lukeed/ms@2.0.2': - resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA== } + engines: { node: '>=8' } '@metaplex-foundation/beet-solana@0.1.1': - resolution: {integrity: sha512-QV2DbxjaJWLkMvn12OC09g+r7a6R0uNwf8msYuOUSw4cG7amXzvFb7s0bh4IxY3Rk8/0ma0PfKi/FEdC7Hi4Pg==} + resolution: + { integrity: sha512-QV2DbxjaJWLkMvn12OC09g+r7a6R0uNwf8msYuOUSw4cG7amXzvFb7s0bh4IxY3Rk8/0ma0PfKi/FEdC7Hi4Pg== } '@metaplex-foundation/beet-solana@0.3.1': - resolution: {integrity: sha512-tgyEl6dvtLln8XX81JyBvWjIiEcjTkUwZbrM5dIobTmoqMuGewSyk9CClno8qsMsFdB5T3jC91Rjeqmu/6xk2g==} + resolution: + { integrity: sha512-tgyEl6dvtLln8XX81JyBvWjIiEcjTkUwZbrM5dIobTmoqMuGewSyk9CClno8qsMsFdB5T3jC91Rjeqmu/6xk2g== } '@metaplex-foundation/beet-solana@0.4.1': - resolution: {integrity: sha512-/6o32FNUtwK8tjhotrvU/vorP7umBuRFvBZrC6XCk51aKidBHe5LPVPA5AjGPbV3oftMfRuXPNd9yAGeEqeCDQ==} + resolution: + { integrity: sha512-/6o32FNUtwK8tjhotrvU/vorP7umBuRFvBZrC6XCk51aKidBHe5LPVPA5AjGPbV3oftMfRuXPNd9yAGeEqeCDQ== } '@metaplex-foundation/beet@0.2.0': - resolution: {integrity: sha512-H570hkJxmx/FxET1OggPPLkPL7psYQa71rNI9NJjYRM8WXdrEvmI/IRIEUW2KR6RqwWWN3FvlRHnKoQUV/lQtA==} + resolution: + { integrity: sha512-H570hkJxmx/FxET1OggPPLkPL7psYQa71rNI9NJjYRM8WXdrEvmI/IRIEUW2KR6RqwWWN3FvlRHnKoQUV/lQtA== } '@metaplex-foundation/beet@0.4.0': - resolution: {integrity: sha512-2OAKJnLatCc3mBXNL0QmWVQKAWK2C7XDfepgL0p/9+8oSx4bmRAFHFqptl1A/C0U5O3dxGwKfmKluW161OVGcA==} + resolution: + { integrity: sha512-2OAKJnLatCc3mBXNL0QmWVQKAWK2C7XDfepgL0p/9+8oSx4bmRAFHFqptl1A/C0U5O3dxGwKfmKluW161OVGcA== } '@metaplex-foundation/beet@0.6.1': - resolution: {integrity: sha512-OYgnijLFzw0cdUlRKH5POp0unQECPOW9muJ2X3QIVyak5G6I6l/rKo72sICgPLIFKdmsi2jmnkuLY7wp14iXdw==} + resolution: + { integrity: sha512-OYgnijLFzw0cdUlRKH5POp0unQECPOW9muJ2X3QIVyak5G6I6l/rKo72sICgPLIFKdmsi2jmnkuLY7wp14iXdw== } '@metaplex-foundation/beet@0.7.2': - resolution: {integrity: sha512-K+g3WhyFxKPc0xIvcIjNyV1eaTVJTiuaHZpig7Xx0MuYRMoJLLvhLTnUXhFdR5Tu2l2QSyKwfyXDgZlzhULqFg==} + resolution: + { integrity: sha512-K+g3WhyFxKPc0xIvcIjNyV1eaTVJTiuaHZpig7Xx0MuYRMoJLLvhLTnUXhFdR5Tu2l2QSyKwfyXDgZlzhULqFg== } '@metaplex-foundation/cusper@0.0.2': - resolution: {integrity: sha512-S9RulC2fFCFOQraz61bij+5YCHhSO9llJegK8c8Y6731fSi6snUSQJdCUqYS8AIgR0TKbQvdvgSyIIdbDFZbBA==} + resolution: + { integrity: sha512-S9RulC2fFCFOQraz61bij+5YCHhSO9llJegK8c8Y6731fSi6snUSQJdCUqYS8AIgR0TKbQvdvgSyIIdbDFZbBA== } '@metaplex-foundation/js@0.11.7': - resolution: {integrity: sha512-zL3Ac++8lUOoKjjhDArdNSimvVYcOqg4uhxfBVRKLV6H8q6E/sOepXuzYSLXxzYWdvapYWEPKFdEDFluYzk7DA==} - engines: {node: ^14.0 || >=16.0} + resolution: + { integrity: sha512-zL3Ac++8lUOoKjjhDArdNSimvVYcOqg4uhxfBVRKLV6H8q6E/sOepXuzYSLXxzYWdvapYWEPKFdEDFluYzk7DA== } + engines: { node: ^14.0 || >=16.0 } deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@metaplex-foundation/mpl-auction-house@2.5.1': - resolution: {integrity: sha512-O+IAdYVaoOvgACB8pm+1lF5BNEjl0COkqny2Ho8KQZwka6aC/vHbZ239yRwAMtJhf5992BPFdT4oifjyE0O+Mw==} + resolution: + { integrity: sha512-O+IAdYVaoOvgACB8pm+1lF5BNEjl0COkqny2Ho8KQZwka6aC/vHbZ239yRwAMtJhf5992BPFdT4oifjyE0O+Mw== } '@metaplex-foundation/mpl-candy-machine@4.7.1': - resolution: {integrity: sha512-tBNRAfBE/rYy9pe2aJD4gTFw+pgQ11o3AJjoYGB4+05ow0VjJMSt6kQGzHm2LRPgdLY4diKAq8qHvgsbV5ikNQ==} + resolution: + { integrity: sha512-tBNRAfBE/rYy9pe2aJD4gTFw+pgQ11o3AJjoYGB4+05ow0VjJMSt6kQGzHm2LRPgdLY4diKAq8qHvgsbV5ikNQ== } '@metaplex-foundation/mpl-core@0.6.1': - resolution: {integrity: sha512-6R4HkfAqU2EUakNbVLcCmka0YuQTLGTbHJ62ig765+NRWuB2HNGUQ1HfHcRsGnyxhlCvwKK79JE01XUjFE+dzw==} + resolution: + { integrity: sha512-6R4HkfAqU2EUakNbVLcCmka0YuQTLGTbHJ62ig765+NRWuB2HNGUQ1HfHcRsGnyxhlCvwKK79JE01XUjFE+dzw== } deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@metaplex-foundation/mpl-token-metadata@2.13.0': - resolution: {integrity: sha512-Fl/8I0L9rv4bKTV/RAl5YIbJe9SnQPInKvLz+xR1fEc4/VQkuCn3RPgypfUMEKWmCznzaw4sApDxy6CFS4qmJw==} + resolution: + { integrity: sha512-Fl/8I0L9rv4bKTV/RAl5YIbJe9SnQPInKvLz+xR1fEc4/VQkuCn3RPgypfUMEKWmCznzaw4sApDxy6CFS4qmJw== } '@meteora-ag/cp-amm-sdk@1.4.5': - resolution: {integrity: sha512-Uw1tj0llVf61FUkkx3aRcT868d/2fiBK+7GpP/rH3yZaDC15JBmjtCpx6ghS8jA2ZM4qvMb1cnmF3hLH9C3wAA==} + resolution: + { integrity: sha512-Uw1tj0llVf61FUkkx3aRcT868d/2fiBK+7GpP/rH3yZaDC15JBmjtCpx6ghS8jA2ZM4qvMb1cnmF3hLH9C3wAA== } '@meteora-ag/dlmm@1.7.5': - resolution: {integrity: sha512-cXfEvAaInhuBwz4pKcMaJHMWbysEEApMTeYnL4kMD38e6YyOht6hfJme2cjaWXct5jAbVvA0AEoFZdjcC/nQhA==} + resolution: + { integrity: sha512-cXfEvAaInhuBwz4pKcMaJHMWbysEEApMTeYnL4kMD38e6YyOht6hfJme2cjaWXct5jAbVvA0AEoFZdjcC/nQhA== } '@napi-rs/wasm-runtime@0.2.11': - resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + resolution: + { integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA== } '@noble/ciphers@1.3.0': - resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} - engines: {node: ^14.21.3 || >=16} + resolution: + { integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw== } + engines: { node: ^14.21.3 || >=16 } '@noble/curves@1.4.2': - resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + resolution: + { integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw== } '@noble/curves@1.8.2': - resolution: {integrity: sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==} - engines: {node: ^14.21.3 || >=16} + resolution: + { integrity: sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g== } + engines: { node: ^14.21.3 || >=16 } '@noble/curves@1.9.0': - resolution: {integrity: sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==} - engines: {node: ^14.21.3 || >=16} + resolution: + { integrity: sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg== } + engines: { node: ^14.21.3 || >=16 } '@noble/curves@1.9.1': - resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} - engines: {node: ^14.21.3 || >=16} + resolution: + { integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA== } + engines: { node: ^14.21.3 || >=16 } '@noble/curves@1.9.2': - resolution: {integrity: sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g==} - engines: {node: ^14.21.3 || >=16} + resolution: + { integrity: sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g== } + engines: { node: ^14.21.3 || >=16 } '@noble/ed25519@1.7.5': - resolution: {integrity: sha512-xuS0nwRMQBvSxDa7UxMb61xTiH3MxTgUfhyPUALVIe0FlOAz4sjELwyDRyUvqeEYfRSG9qNjFIycqLZppg4RSA==} + resolution: + { integrity: sha512-xuS0nwRMQBvSxDa7UxMb61xTiH3MxTgUfhyPUALVIe0FlOAz4sjELwyDRyUvqeEYfRSG9qNjFIycqLZppg4RSA== } '@noble/hashes@1.2.0': - resolution: {integrity: sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==} + resolution: + { integrity: sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ== } '@noble/hashes@1.4.0': - resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} - engines: {node: '>= 16'} + resolution: + { integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== } + engines: { node: '>= 16' } '@noble/hashes@1.7.2': - resolution: {integrity: sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==} - engines: {node: ^14.21.3 || >=16} + resolution: + { integrity: sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ== } + engines: { node: ^14.21.3 || >=16 } '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + resolution: + { integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== } + engines: { node: ^14.21.3 || >=16 } '@noble/secp256k1@1.7.1': - resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} + resolution: + { integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw== } '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== } + engines: { node: '>= 8' } '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== } + engines: { node: '>= 8' } '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== } + engines: { node: '>= 8' } '@nomicfoundation/edr-darwin-arm64@0.11.0': - resolution: {integrity: sha512-aYTVdcSs27XG7ayTzvZ4Yn9z/ABSaUwicrtrYK2NR8IH0ik4N4bWzo/qH8rax6rewVLbHUkGyGYnsy5ZN4iiMw==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-aYTVdcSs27XG7ayTzvZ4Yn9z/ABSaUwicrtrYK2NR8IH0ik4N4bWzo/qH8rax6rewVLbHUkGyGYnsy5ZN4iiMw== } + engines: { node: '>= 18' } '@nomicfoundation/edr-darwin-x64@0.11.0': - resolution: {integrity: sha512-RxX7UYgvJrfcyT/uHUn44Nsy1XaoW+Q1khKMdHKxeW7BrgIi+Lz+siz3bX5vhSoAnKilDPhIVLrnC8zxQhjR2A==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-RxX7UYgvJrfcyT/uHUn44Nsy1XaoW+Q1khKMdHKxeW7BrgIi+Lz+siz3bX5vhSoAnKilDPhIVLrnC8zxQhjR2A== } + engines: { node: '>= 18' } '@nomicfoundation/edr-linux-arm64-gnu@0.11.0': - resolution: {integrity: sha512-J0j+rs0s11FuSipt/ymqrFmpJ7c0FSz1/+FohCIlUXDxFv//+1R/8lkGPjEYFmy8DPpk/iO8mcpqHTGckREbqA==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-J0j+rs0s11FuSipt/ymqrFmpJ7c0FSz1/+FohCIlUXDxFv//+1R/8lkGPjEYFmy8DPpk/iO8mcpqHTGckREbqA== } + engines: { node: '>= 18' } '@nomicfoundation/edr-linux-arm64-musl@0.11.0': - resolution: {integrity: sha512-4r32zkGMN7WT/CMEuW0VjbuEdIeCskHNDMW4SSgQSJOE/N9L1KSLJCSsAbPD3aYE+e4WRDTyOwmuLjeUTcLZKQ==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-4r32zkGMN7WT/CMEuW0VjbuEdIeCskHNDMW4SSgQSJOE/N9L1KSLJCSsAbPD3aYE+e4WRDTyOwmuLjeUTcLZKQ== } + engines: { node: '>= 18' } '@nomicfoundation/edr-linux-x64-gnu@0.11.0': - resolution: {integrity: sha512-SmdncQHLYtVNWLIMyGaY6LpAfamzTDe3fxjkirmJv3CWR5tcEyC6LMui/GsIVnJzXeNJBXAzwl8hTUAxHTM6kQ==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-SmdncQHLYtVNWLIMyGaY6LpAfamzTDe3fxjkirmJv3CWR5tcEyC6LMui/GsIVnJzXeNJBXAzwl8hTUAxHTM6kQ== } + engines: { node: '>= 18' } '@nomicfoundation/edr-linux-x64-musl@0.11.0': - resolution: {integrity: sha512-w6hUqpn/trwiH6SRuRGysj37LsQVCX5XDCA3Xi81sbOaLhbHrNvK9TXWyZmcuzbdTKQQW6VNywcSxDdOiChcJg==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-w6hUqpn/trwiH6SRuRGysj37LsQVCX5XDCA3Xi81sbOaLhbHrNvK9TXWyZmcuzbdTKQQW6VNywcSxDdOiChcJg== } + engines: { node: '>= 18' } '@nomicfoundation/edr-win32-x64-msvc@0.11.0': - resolution: {integrity: sha512-BLmULjRKoH9BsX+c4Na2ypV7NGeJ+M6Zpqj/faPOwleVscDdSr/IhriyPaXCe8dyfwbge7lWsbekiADtPSnB2Q==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-BLmULjRKoH9BsX+c4Na2ypV7NGeJ+M6Zpqj/faPOwleVscDdSr/IhriyPaXCe8dyfwbge7lWsbekiADtPSnB2Q== } + engines: { node: '>= 18' } '@nomicfoundation/edr@0.11.0': - resolution: {integrity: sha512-36WERf8ldvyHR6UAbcYsa+vpbW7tCrJGBwF4gXSsb8+STj1n66Hz85Y/O7B9+8AauX3PhglvV5dKl91tk43mWw==} - engines: {node: '>= 18'} + resolution: + { integrity: sha512-36WERf8ldvyHR6UAbcYsa+vpbW7tCrJGBwF4gXSsb8+STj1n66Hz85Y/O7B9+8AauX3PhglvV5dKl91tk43mWw== } + engines: { node: '>= 18' } '@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.2': - resolution: {integrity: sha512-JaqcWPDZENCvm++lFFGjrDd8mxtf+CtLd2MiXvMNTBD33dContTZ9TWETwNFwg7JTJT5Q9HEecH7FA+HTSsIUw==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-JaqcWPDZENCvm++lFFGjrDd8mxtf+CtLd2MiXvMNTBD33dContTZ9TWETwNFwg7JTJT5Q9HEecH7FA+HTSsIUw== } + engines: { node: '>= 12' } '@nomicfoundation/solidity-analyzer-darwin-x64@0.1.2': - resolution: {integrity: sha512-fZNmVztrSXC03e9RONBT+CiksSeYcxI1wlzqyr0L7hsQlK1fzV+f04g2JtQ1c/Fe74ZwdV6aQBdd6Uwl1052sw==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-fZNmVztrSXC03e9RONBT+CiksSeYcxI1wlzqyr0L7hsQlK1fzV+f04g2JtQ1c/Fe74ZwdV6aQBdd6Uwl1052sw== } + engines: { node: '>= 12' } '@nomicfoundation/solidity-analyzer-linux-arm64-gnu@0.1.2': - resolution: {integrity: sha512-3d54oc+9ZVBuB6nbp8wHylk4xh0N0Gc+bk+/uJae+rUgbOBwQSfuGIbAZt1wBXs5REkSmynEGcqx6DutoK0tPA==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-3d54oc+9ZVBuB6nbp8wHylk4xh0N0Gc+bk+/uJae+rUgbOBwQSfuGIbAZt1wBXs5REkSmynEGcqx6DutoK0tPA== } + engines: { node: '>= 12' } '@nomicfoundation/solidity-analyzer-linux-arm64-musl@0.1.2': - resolution: {integrity: sha512-iDJfR2qf55vgsg7BtJa7iPiFAsYf2d0Tv/0B+vhtnI16+wfQeTbP7teookbGvAo0eJo7aLLm0xfS/GTkvHIucA==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-iDJfR2qf55vgsg7BtJa7iPiFAsYf2d0Tv/0B+vhtnI16+wfQeTbP7teookbGvAo0eJo7aLLm0xfS/GTkvHIucA== } + engines: { node: '>= 12' } '@nomicfoundation/solidity-analyzer-linux-x64-gnu@0.1.2': - resolution: {integrity: sha512-9dlHMAt5/2cpWyuJ9fQNOUXFB/vgSFORg1jpjX1Mh9hJ/MfZXlDdHQ+DpFCs32Zk5pxRBb07yGvSHk9/fezL+g==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-9dlHMAt5/2cpWyuJ9fQNOUXFB/vgSFORg1jpjX1Mh9hJ/MfZXlDdHQ+DpFCs32Zk5pxRBb07yGvSHk9/fezL+g== } + engines: { node: '>= 12' } '@nomicfoundation/solidity-analyzer-linux-x64-musl@0.1.2': - resolution: {integrity: sha512-GzzVeeJob3lfrSlDKQw2bRJ8rBf6mEYaWY+gW0JnTDHINA0s2gPR4km5RLIj1xeZZOYz4zRw+AEeYgLRqB2NXg==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-GzzVeeJob3lfrSlDKQw2bRJ8rBf6mEYaWY+gW0JnTDHINA0s2gPR4km5RLIj1xeZZOYz4zRw+AEeYgLRqB2NXg== } + engines: { node: '>= 12' } '@nomicfoundation/solidity-analyzer-win32-x64-msvc@0.1.2': - resolution: {integrity: sha512-Fdjli4DCcFHb4Zgsz0uEJXZ2K7VEO+w5KVv7HmT7WO10iODdU9csC2az4jrhEsRtiR9Gfd74FlG0NYlw1BMdyA==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-Fdjli4DCcFHb4Zgsz0uEJXZ2K7VEO+w5KVv7HmT7WO10iODdU9csC2az4jrhEsRtiR9Gfd74FlG0NYlw1BMdyA== } + engines: { node: '>= 12' } '@nomicfoundation/solidity-analyzer@0.1.2': - resolution: {integrity: sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA== } + engines: { node: '>= 12' } '@openzeppelin/contracts-upgradeable@3.4.2-solc-0.7': - resolution: {integrity: sha512-I5iKKS8U9L1XdSxsNAIBQekN0U9hTgdleoyntIdR7Jy3U/z/NZ/1oUM0v5HnUMrmn/bXLvYL94rBvaLF++Ndnw==} + resolution: + { integrity: sha512-I5iKKS8U9L1XdSxsNAIBQekN0U9hTgdleoyntIdR7Jy3U/z/NZ/1oUM0v5HnUMrmn/bXLvYL94rBvaLF++Ndnw== } '@openzeppelin/contracts@4.9.6': - resolution: {integrity: sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dbuHkgjuwDFUmaFauBCboQMGB/S5UqUl2y54X99BmA==} + resolution: + { integrity: sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dbuHkgjuwDFUmaFauBCboQMGB/S5UqUl2y54X99BmA== } '@orca-so/common-sdk@0.6.11': - resolution: {integrity: sha512-7MJs71F8XJsYCx3+agsLc3MMGnzAC8l3FDbUq0qP3lEPI6B5IS/u2iKU4rWkK3IqIkynWcvuYL6WCi+90vu52w==} + resolution: + { integrity: sha512-7MJs71F8XJsYCx3+agsLc3MMGnzAC8l3FDbUq0qP3lEPI6B5IS/u2iKU4rWkK3IqIkynWcvuYL6WCi+90vu52w== } peerDependencies: '@solana/spl-token': ^0.4.12 '@solana/web3.js': ^1.90.0 '@orca-so/tx-sender@3.0.1': - resolution: {integrity: sha512-WuH9ACW4iIZgeF8cmFZxlQkI0pI9W/2VRt0k2AsPUp96scNqDTJFYsyAnvXxOXCloVr7sunU4MSMCTm6d/jTew==} + resolution: + { integrity: sha512-WuH9ACW4iIZgeF8cmFZxlQkI0pI9W/2VRt0k2AsPUp96scNqDTJFYsyAnvXxOXCloVr7sunU4MSMCTm6d/jTew== } peerDependencies: '@solana/kit': ^5.0.0 '@orca-so/whirlpools-client@7.0.0': - resolution: {integrity: sha512-bpWG+cXe2VqLaH9wBvtsZKdnaH0iWO/dmGCgdrntxOarsS28FTF4fh5ahZnf9gYP8DiwN/8DrOKiXWO4Jn49/A==} + resolution: + { integrity: sha512-bpWG+cXe2VqLaH9wBvtsZKdnaH0iWO/dmGCgdrntxOarsS28FTF4fh5ahZnf9gYP8DiwN/8DrOKiXWO4Jn49/A== } peerDependencies: '@solana/kit': ^5.0.0 '@orca-so/whirlpools-core@3.1.0': - resolution: {integrity: sha512-PuP5II56ZHkjjVaE6C7lgQR4x+9HtyvEzUWw2hpHW+Be80MerEjqEuBpUXECTgiG+pPCp1RBzmN/1LbyAT8uAw==} + resolution: + { integrity: sha512-PuP5II56ZHkjjVaE6C7lgQR4x+9HtyvEzUWw2hpHW+Be80MerEjqEuBpUXECTgiG+pPCp1RBzmN/1LbyAT8uAw== } '@orca-so/whirlpools-core@3.1.1': - resolution: {integrity: sha512-pZqemzQrtLW214yTpc117SNGMk3KIxVw9i77DT5235TUj+v+MkX0EZcriAkDfAeVMr0Il6XKS9hTz8ynZ8lXcA==} + resolution: + { integrity: sha512-pZqemzQrtLW214yTpc117SNGMk3KIxVw9i77DT5235TUj+v+MkX0EZcriAkDfAeVMr0Il6XKS9hTz8ynZ8lXcA== } '@orca-so/whirlpools@8.0.1': - resolution: {integrity: sha512-4R+BhxZWRL1nVAuV0rz6MI8w+8HiUuv5PUQzEmvyy/Cj8mgfZUJxU4DvNA8E9IM3zpZRQM/sDR9Tc0jbjQvlFQ==} + resolution: + { integrity: sha512-4R+BhxZWRL1nVAuV0rz6MI8w+8HiUuv5PUQzEmvyy/Cj8mgfZUJxU4DvNA8E9IM3zpZRQM/sDR9Tc0jbjQvlFQ== } peerDependencies: '@solana/kit': ^5.0.0 '@pancakeswap/chains@0.5.1': - resolution: {integrity: sha512-wIYRrC7iQfd/ILe4/SJ6nQ+GZmHcy3ylKYH8O8Thfd1WmbJ9G4qnQkL0wJBmA2NUkMO/qceWQAthr2BCosl/ZA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-wIYRrC7iQfd/ILe4/SJ6nQ+GZmHcy3ylKYH8O8Thfd1WmbJ9G4qnQkL0wJBmA2NUkMO/qceWQAthr2BCosl/ZA== } + engines: { node: '>=10' } '@pancakeswap/chains@0.6.0': - resolution: {integrity: sha512-deg+CtkP8ZzCQ4W9XnTUm89jtzkhmg8FPczwStqpysL0HSFWqHsCh+tsZRHskfVPS8AqnlSA7uo1Z7nv9KrOMA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-deg+CtkP8ZzCQ4W9XnTUm89jtzkhmg8FPczwStqpysL0HSFWqHsCh+tsZRHskfVPS8AqnlSA7uo1Z7nv9KrOMA== } + engines: { node: '>=10' } '@pancakeswap/infinity-sdk@1.0.5': - resolution: {integrity: sha512-NPoYyiJIzPu+HAv0qp5UBWKRLIqEejSRD1TVicB9ylJsamUMW8Q8m+LFqX6lVxX/ykI/7sOX5RdhU+dhLOQsTQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-NPoYyiJIzPu+HAv0qp5UBWKRLIqEejSRD1TVicB9ylJsamUMW8Q8m+LFqX6lVxX/ykI/7sOX5RdhU+dhLOQsTQ== } + engines: { node: '>=10' } '@pancakeswap/multicall@3.7.2': - resolution: {integrity: sha512-HE64KWfdZwsgB8ZFnZakdFuqBCQullfjm/wxLHjRnWW9hQnEcxZES2ZQ9pW1loIeZ4BC6AV+YfDJ/xxXzb+Lrw==} + resolution: + { integrity: sha512-HE64KWfdZwsgB8ZFnZakdFuqBCQullfjm/wxLHjRnWW9hQnEcxZES2ZQ9pW1loIeZ4BC6AV+YfDJ/xxXzb+Lrw== } '@pancakeswap/permit2-sdk@1.1.5': - resolution: {integrity: sha512-23mlRyaR2OP3u9rQn3gSZA4NNlqgRR6AnHhG8etYyKzGfFz8joJP1JcRxfRkWJqtLSjUPB0o3oMWNNzsSRT26Q==} + resolution: + { integrity: sha512-23mlRyaR2OP3u9rQn3gSZA4NNlqgRR6AnHhG8etYyKzGfFz8joJP1JcRxfRkWJqtLSjUPB0o3oMWNNzsSRT26Q== } '@pancakeswap/sdk@5.8.12': - resolution: {integrity: sha512-eubv5+9ilGIXRB1C4AwCwcnDQU/JzlXBGmJq+JxFRSfh8+XMtuLf+KZyWN5i0M0WK3z+hP/Xwo69j+6vRfJJuA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-eubv5+9ilGIXRB1C4AwCwcnDQU/JzlXBGmJq+JxFRSfh8+XMtuLf+KZyWN5i0M0WK3z+hP/Xwo69j+6vRfJJuA== } + engines: { node: '>=10' } '@pancakeswap/sdk@5.8.16': - resolution: {integrity: sha512-vv6fOZM8jZlcdS7M8kLSs7eP3jDYKZ6w/OjjfuQPEE2+1SVN6LFZzkuXzkNGtKAYgf6N7lUzFBPyStaXEUynsw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-vv6fOZM8jZlcdS7M8kLSs7eP3jDYKZ6w/OjjfuQPEE2+1SVN6LFZzkuXzkNGtKAYgf6N7lUzFBPyStaXEUynsw== } + engines: { node: '>=10' } '@pancakeswap/smart-router@7.5.2': - resolution: {integrity: sha512-2aROSXQJWoOfrib/mUkM9FnOjl+oihhrXe9XpHcjYEOtzEIYAf5C34CTUxgUpflplaHNVkCHxJ+mbaP0xs7ALQ==} + resolution: + { integrity: sha512-2aROSXQJWoOfrib/mUkM9FnOjl+oihhrXe9XpHcjYEOtzEIYAf5C34CTUxgUpflplaHNVkCHxJ+mbaP0xs7ALQ== } '@pancakeswap/stable-swap-sdk@2.0.9': - resolution: {integrity: sha512-51EGjmaefA/c2NnCz2yAzy2DHtWfVwy1t0XQrDj79RM4+NtjuMe9lyaJZozcl+J/rQPB1Cb5p9+8IiX52J+7mg==} + resolution: + { integrity: sha512-51EGjmaefA/c2NnCz2yAzy2DHtWfVwy1t0XQrDj79RM4+NtjuMe9lyaJZozcl+J/rQPB1Cb5p9+8IiX52J+7mg== } '@pancakeswap/swap-sdk-core@1.3.0': - resolution: {integrity: sha512-nkeDs3GyNfvRGsTbTAO30yl6ccOTr5WQERsKAaxKTe1fbGvpDRFLo3nlR1ZddRCj+RYZSS+B4Sll4A39k7nFDQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-nkeDs3GyNfvRGsTbTAO30yl6ccOTr5WQERsKAaxKTe1fbGvpDRFLo3nlR1ZddRCj+RYZSS+B4Sll4A39k7nFDQ== } + engines: { node: '>=10' } '@pancakeswap/swap-sdk-core@1.5.0': - resolution: {integrity: sha512-XedyPJ7DCuxcSSTPg7kX0E5UVELWoqQSri4Gv78qWT9fQ590ikF3tFlNF7fcc4yo/TSRzwXgC9WqYjeFJ8lKwA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-XedyPJ7DCuxcSSTPg7kX0E5UVELWoqQSri4Gv78qWT9fQ590ikF3tFlNF7fcc4yo/TSRzwXgC9WqYjeFJ8lKwA== } + engines: { node: '>=10' } '@pancakeswap/swap-sdk-evm@1.1.1': - resolution: {integrity: sha512-z5nDhNf9NzbZFElq8pC2MPzWrI5WDT9mVQ1NkWDOh1wqJijv4+jCTBCoOi9cLsBGBkoMJ/yZYZ2YlxBcSwVjAQ==} + resolution: + { integrity: sha512-z5nDhNf9NzbZFElq8pC2MPzWrI5WDT9mVQ1NkWDOh1wqJijv4+jCTBCoOi9cLsBGBkoMJ/yZYZ2YlxBcSwVjAQ== } '@pancakeswap/swap-sdk-evm@1.1.5': - resolution: {integrity: sha512-ZQaz86yELhtar9+8HfYrt9L+IWFJXU/viKj6JiDJZhZ8zDBErxrzHYswFMvmX9bsnmgoLpWpcallIEwqDmMbJw==} + resolution: + { integrity: sha512-ZQaz86yELhtar9+8HfYrt9L+IWFJXU/viKj6JiDJZhZ8zDBErxrzHYswFMvmX9bsnmgoLpWpcallIEwqDmMbJw== } '@pancakeswap/swap-sdk-solana@1.1.4': - resolution: {integrity: sha512-xLArIRzx5CwHuXHlvu/RyImpKyB3V3mXsb4chybfKyAXhYjiD9OOXIzLLXzpdkJFa8VQ0kRIMtUrFYA5i42g7A==} + resolution: + { integrity: sha512-xLArIRzx5CwHuXHlvu/RyImpKyB3V3mXsb4chybfKyAXhYjiD9OOXIzLLXzpdkJFa8VQ0kRIMtUrFYA5i42g7A== } '@pancakeswap/token-lists@0.0.16': - resolution: {integrity: sha512-Mf/Y7wm7HdHMBeOKrRcIbGBDjt3/xlerAolRXaRDAYtHbyn7sE8Q/azq9XhAR2OFIc5qJuLejlXFrWxnQFr9tw==} + resolution: + { integrity: sha512-Mf/Y7wm7HdHMBeOKrRcIbGBDjt3/xlerAolRXaRDAYtHbyn7sE8Q/azq9XhAR2OFIc5qJuLejlXFrWxnQFr9tw== } peerDependencies: '@reduxjs/toolkit': ^1.9.1 jotai: 2.12.5 @@ -1569,10 +1860,12 @@ packages: optional: true '@pancakeswap/tokens@0.7.8': - resolution: {integrity: sha512-nOyWG1SSfx8Hk/P+iJkj/NU4pvz+gpRJ6V6l+CM6WsgjFrTdQKDm3yUrzeurwZspJfwwEvWep0mYGxDj85zXdA==} + resolution: + { integrity: sha512-nOyWG1SSfx8Hk/P+iJkj/NU4pvz+gpRJ6V6l+CM6WsgjFrTdQKDm3yUrzeurwZspJfwwEvWep0mYGxDj85zXdA== } '@pancakeswap/universal-router-sdk@1.4.14': - resolution: {integrity: sha512-NIjzWG0UXngXMOUPyT4KDcnrEtnuFoQc5H+6eTg1Af0WMEgnkrN4HRBrZECfGSb+vCg9aak0kPv5MQpXjRuLkg==} + resolution: + { integrity: sha512-NIjzWG0UXngXMOUPyT4KDcnrEtnuFoQc5H+6eTg1Af0WMEgnkrN4HRBrZECfGSb+vCg9aak0kPv5MQpXjRuLkg== } peerDependencies: abitype: '*' lodash: '*' @@ -1580,376 +1873,472 @@ packages: viem: 2.31.3 '@pancakeswap/v2-sdk@1.1.1': - resolution: {integrity: sha512-FlPEk9Jpq3YcLZ7DEkDXqx5g57+Vxk3sJAB2aSU2UypO3fKGwB/yt/zaeFT8mLzd9p6IlBJ912Mxo1nGRhENlA==} + resolution: + { integrity: sha512-FlPEk9Jpq3YcLZ7DEkDXqx5g57+Vxk3sJAB2aSU2UypO3fKGwB/yt/zaeFT8mLzd9p6IlBJ912Mxo1nGRhENlA== } '@pancakeswap/v2-sdk@1.1.5': - resolution: {integrity: sha512-IaO+r5pOxx4TyGZc+mUyZI76koeXzk7UU5MQtyMYPlngCoAIP8E5YqrUaEvuSYiFaJqSjawyenT4vHe9HmnJoQ==} + resolution: + { integrity: sha512-IaO+r5pOxx4TyGZc+mUyZI76koeXzk7UU5MQtyMYPlngCoAIP8E5YqrUaEvuSYiFaJqSjawyenT4vHe9HmnJoQ== } '@pancakeswap/v3-core@1.0.2': - resolution: {integrity: sha512-9aZU8I1J6SbZOSW7NcNxuyaAC17tGkOaZJM9aJgvl6MMUOExpq0i0EC/jc3HxWbpC8sbZL+8eG544NEJs8CS+w==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-9aZU8I1J6SbZOSW7NcNxuyaAC17tGkOaZJM9aJgvl6MMUOExpq0i0EC/jc3HxWbpC8sbZL+8eG544NEJs8CS+w== } + engines: { node: '>=10' } '@pancakeswap/v3-periphery@1.0.2': - resolution: {integrity: sha512-kWQhJsAG5Ij1cubKlX0JuJ3GEPFiPBR+NQt77SxHNjk62eallLyfbWJYk2NMnTO0crbjBFO5GKKQAXfp2n76vg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-kWQhJsAG5Ij1cubKlX0JuJ3GEPFiPBR+NQt77SxHNjk62eallLyfbWJYk2NMnTO0crbjBFO5GKKQAXfp2n76vg== } + engines: { node: '>=10' } '@pancakeswap/v3-sdk@3.9.1': - resolution: {integrity: sha512-rOTLn2kdB8RmkThU36TDX/QSpkVBRNyW17qOYNwhHgjdrKXah4iJZZu4dJzwN/1IywjpQ1UIcmI57WWQL5Nj3A==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-rOTLn2kdB8RmkThU36TDX/QSpkVBRNyW17qOYNwhHgjdrKXah4iJZZu4dJzwN/1IywjpQ1UIcmI57WWQL5Nj3A== } + engines: { node: '>=10' } '@pancakeswap/v3-sdk@3.9.5': - resolution: {integrity: sha512-rqtDADhS17mplAnr8VyTxOYGiL4SEw1g7NZdRMFwcvxH5wzskSzNEfg4cFsLWb+mD3FyX8BXXWrvADLsQqP2mg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-rqtDADhS17mplAnr8VyTxOYGiL4SEw1g7NZdRMFwcvxH5wzskSzNEfg4cFsLWb+mD3FyX8BXXWrvADLsQqP2mg== } + engines: { node: '>=10' } '@pancakeswap/v4-sdk@0.1.8': - resolution: {integrity: sha512-5n5cmtBMYo7bnhmxhuwfv4P/MSaeZbvNsqsUjl6UsNnI9ylPx4ttTvj/uxhytnjpSA2wwSZUvwLys/w3rHrWGw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-5n5cmtBMYo7bnhmxhuwfv4P/MSaeZbvNsqsUjl6UsNnI9ylPx4ttTvj/uxhytnjpSA2wwSZUvwLys/w3rHrWGw== } + engines: { node: '>=10' } '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== } + engines: { node: '>=14' } '@pkgr/core@0.2.4': - resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + resolution: + { integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw== } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } '@randlabs/communication-bridge@1.0.1': - resolution: {integrity: sha512-CzS0U8IFfXNK7QaJFE4pjbxDGfPjbXBEsEaCn9FN15F+ouSAEUQkva3Gl66hrkBZOGexKFEWMwUHIDKpZ2hfVg==} + resolution: + { integrity: sha512-CzS0U8IFfXNK7QaJFE4pjbxDGfPjbXBEsEaCn9FN15F+ouSAEUQkva3Gl66hrkBZOGexKFEWMwUHIDKpZ2hfVg== } '@randlabs/myalgo-connect@1.4.2': - resolution: {integrity: sha512-K9hEyUi7G8tqOp7kWIALJLVbGCByhilcy6123WfcorxWwiE1sbQupPyIU5f3YdQK6wMjBsyTWiLW52ZBMp7sXA==} + resolution: + { integrity: sha512-K9hEyUi7G8tqOp7kWIALJLVbGCByhilcy6123WfcorxWwiE1sbQupPyIU5f3YdQK6wMjBsyTWiLW52ZBMp7sXA== } '@raydium-io/raydium-sdk-v2@0.1.141-alpha': - resolution: {integrity: sha512-mBg+IWzAO19debQH6LnGOF8Ywy0l+W/J6tviFvywGYLJ6pw72Qe8quojtY48qxocqboAd3dv+Bbi3DVyyF+bzg==} + resolution: + { integrity: sha512-mBg+IWzAO19debQH6LnGOF8Ywy0l+W/J6tviFvywGYLJ6pw72Qe8quojtY48qxocqboAd3dv+Bbi3DVyyF+bzg== } '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + resolution: + { integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== } '@scure/base@1.1.9': - resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} + resolution: + { integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== } '@scure/base@1.2.5': - resolution: {integrity: sha512-9rE6EOVeIQzt5TSu4v+K523F8u6DhBsoZWPGKlnCshhlDhy0kJzUX4V+tr2dWmzF1GdekvThABoEQBGBQI7xZw==} + resolution: + { integrity: sha512-9rE6EOVeIQzt5TSu4v+K523F8u6DhBsoZWPGKlnCshhlDhy0kJzUX4V+tr2dWmzF1GdekvThABoEQBGBQI7xZw== } '@scure/bip32@1.1.5': - resolution: {integrity: sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw==} + resolution: + { integrity: sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw== } '@scure/bip32@1.4.0': - resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + resolution: + { integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg== } '@scure/bip32@1.7.0': - resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + resolution: + { integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== } '@scure/bip39@1.1.1': - resolution: {integrity: sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg==} + resolution: + { integrity: sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg== } '@scure/bip39@1.3.0': - resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + resolution: + { integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ== } '@scure/bip39@1.6.0': - resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + resolution: + { integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A== } '@sentry/core@5.30.0': - resolution: {integrity: sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg== } + engines: { node: '>=6' } '@sentry/hub@5.30.0': - resolution: {integrity: sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ== } + engines: { node: '>=6' } '@sentry/minimal@5.30.0': - resolution: {integrity: sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw== } + engines: { node: '>=6' } '@sentry/node@5.30.0': - resolution: {integrity: sha512-Br5oyVBF0fZo6ZS9bxbJZG4ApAjRqAnqFFurMVJJdunNb80brh7a5Qva2kjhm+U6r9NJAB5OmDyPkA1Qnt+QVg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-Br5oyVBF0fZo6ZS9bxbJZG4ApAjRqAnqFFurMVJJdunNb80brh7a5Qva2kjhm+U6r9NJAB5OmDyPkA1Qnt+QVg== } + engines: { node: '>=6' } '@sentry/tracing@5.30.0': - resolution: {integrity: sha512-dUFowCr0AIMwiLD7Fs314Mdzcug+gBVo/+NCMyDw8tFxJkwWAKl7Qa2OZxLQ0ZHjakcj1hNKfCQJ9rhyfOl4Aw==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-dUFowCr0AIMwiLD7Fs314Mdzcug+gBVo/+NCMyDw8tFxJkwWAKl7Qa2OZxLQ0ZHjakcj1hNKfCQJ9rhyfOl4Aw== } + engines: { node: '>=6' } '@sentry/types@5.30.0': - resolution: {integrity: sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw== } + engines: { node: '>=6' } '@sentry/utils@5.30.0': - resolution: {integrity: sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww== } + engines: { node: '>=6' } '@sideway/address@4.1.5': - resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + resolution: + { integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== } '@sideway/formula@3.0.1': - resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + resolution: + { integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== } '@sideway/pinpoint@2.0.0': - resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + resolution: + { integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== } '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + resolution: + { integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== } '@sinclair/typebox@0.33.22': - resolution: {integrity: sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==} + resolution: + { integrity: sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ== } '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + resolution: + { integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== } '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + resolution: + { integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== } '@smithy/abort-controller@4.0.2': - resolution: {integrity: sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw== } + engines: { node: '>=18.0.0' } '@smithy/chunked-blob-reader-native@4.0.0': - resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig== } + engines: { node: '>=18.0.0' } '@smithy/chunked-blob-reader@5.0.0': - resolution: {integrity: sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw== } + engines: { node: '>=18.0.0' } '@smithy/config-resolver@4.1.1': - resolution: {integrity: sha512-FZUtpiDnPZQmuIl4lfbdO+u3foNLmRCKct/2w2nRwgB99Yvaq4SHcfxyzMfxkyBrBmgnF1kdXzhHNXN7ycDvWg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-FZUtpiDnPZQmuIl4lfbdO+u3foNLmRCKct/2w2nRwgB99Yvaq4SHcfxyzMfxkyBrBmgnF1kdXzhHNXN7ycDvWg== } + engines: { node: '>=18.0.0' } '@smithy/core@3.3.1': - resolution: {integrity: sha512-W7AppgQD3fP1aBmo8wWo0id5zeR2/aYRy067vZsDVaa6v/mdhkg6DxXwEVuSPjZl+ZnvWAQbUMCd5ckw38+tHQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-W7AppgQD3fP1aBmo8wWo0id5zeR2/aYRy067vZsDVaa6v/mdhkg6DxXwEVuSPjZl+ZnvWAQbUMCd5ckw38+tHQ== } + engines: { node: '>=18.0.0' } '@smithy/credential-provider-imds@4.0.3': - resolution: {integrity: sha512-UdNvGjZnunS9+45gHYtVXDynoWH1X0tYY0pS368k1zUZum6Mm4ivU4Se0WhFJf8jNocD+p94khzTtrx4ha3OOQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-UdNvGjZnunS9+45gHYtVXDynoWH1X0tYY0pS368k1zUZum6Mm4ivU4Se0WhFJf8jNocD+p94khzTtrx4ha3OOQ== } + engines: { node: '>=18.0.0' } '@smithy/eventstream-codec@4.0.2': - resolution: {integrity: sha512-p+f2kLSK7ZrXVfskU/f5dzksKTewZk8pJLPvER3aFHPt76C2MxD9vNatSfLzzQSQB4FNO96RK4PSXfhD1TTeMQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-p+f2kLSK7ZrXVfskU/f5dzksKTewZk8pJLPvER3aFHPt76C2MxD9vNatSfLzzQSQB4FNO96RK4PSXfhD1TTeMQ== } + engines: { node: '>=18.0.0' } '@smithy/eventstream-serde-browser@4.0.2': - resolution: {integrity: sha512-CepZCDs2xgVUtH7ZZ7oDdZFH8e6Y2zOv8iiX6RhndH69nlojCALSKK+OXwZUgOtUZEUaZ5e1hULVCHYbCn7pug==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-CepZCDs2xgVUtH7ZZ7oDdZFH8e6Y2zOv8iiX6RhndH69nlojCALSKK+OXwZUgOtUZEUaZ5e1hULVCHYbCn7pug== } + engines: { node: '>=18.0.0' } '@smithy/eventstream-serde-config-resolver@4.1.0': - resolution: {integrity: sha512-1PI+WPZ5TWXrfj3CIoKyUycYynYJgZjuQo8U+sphneOtjsgrttYybdqESFReQrdWJ+LKt6NEdbYzmmfDBmjX2A==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-1PI+WPZ5TWXrfj3CIoKyUycYynYJgZjuQo8U+sphneOtjsgrttYybdqESFReQrdWJ+LKt6NEdbYzmmfDBmjX2A== } + engines: { node: '>=18.0.0' } '@smithy/eventstream-serde-node@4.0.2': - resolution: {integrity: sha512-C5bJ/C6x9ENPMx2cFOirspnF9ZsBVnBMtP6BdPl/qYSuUawdGQ34Lq0dMcf42QTjUZgWGbUIZnz6+zLxJlb9aw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-C5bJ/C6x9ENPMx2cFOirspnF9ZsBVnBMtP6BdPl/qYSuUawdGQ34Lq0dMcf42QTjUZgWGbUIZnz6+zLxJlb9aw== } + engines: { node: '>=18.0.0' } '@smithy/eventstream-serde-universal@4.0.2': - resolution: {integrity: sha512-St8h9JqzvnbB52FtckiHPN4U/cnXcarMniXRXTKn0r4b4XesZOGiAyUdj1aXbqqn1icSqBlzzUsCl6nPB018ng==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-St8h9JqzvnbB52FtckiHPN4U/cnXcarMniXRXTKn0r4b4XesZOGiAyUdj1aXbqqn1icSqBlzzUsCl6nPB018ng== } + engines: { node: '>=18.0.0' } '@smithy/fetch-http-handler@5.0.2': - resolution: {integrity: sha512-+9Dz8sakS9pe7f2cBocpJXdeVjMopUDLgZs1yWeu7h++WqSbjUYv/JAJwKwXw1HV6gq1jyWjxuyn24E2GhoEcQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-+9Dz8sakS9pe7f2cBocpJXdeVjMopUDLgZs1yWeu7h++WqSbjUYv/JAJwKwXw1HV6gq1jyWjxuyn24E2GhoEcQ== } + engines: { node: '>=18.0.0' } '@smithy/hash-blob-browser@4.0.2': - resolution: {integrity: sha512-3g188Z3DyhtzfBRxpZjU8R9PpOQuYsbNnyStc/ZVS+9nVX1f6XeNOa9IrAh35HwwIZg+XWk8bFVtNINVscBP+g==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-3g188Z3DyhtzfBRxpZjU8R9PpOQuYsbNnyStc/ZVS+9nVX1f6XeNOa9IrAh35HwwIZg+XWk8bFVtNINVscBP+g== } + engines: { node: '>=18.0.0' } '@smithy/hash-node@4.0.2': - resolution: {integrity: sha512-VnTpYPnRUE7yVhWozFdlxcYknv9UN7CeOqSrMH+V877v4oqtVYuoqhIhtSjmGPvYrYnAkaM61sLMKHvxL138yg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-VnTpYPnRUE7yVhWozFdlxcYknv9UN7CeOqSrMH+V877v4oqtVYuoqhIhtSjmGPvYrYnAkaM61sLMKHvxL138yg== } + engines: { node: '>=18.0.0' } '@smithy/hash-stream-node@4.0.2': - resolution: {integrity: sha512-POWDuTznzbIwlEXEvvXoPMS10y0WKXK790soe57tFRfvf4zBHyzE529HpZMqmDdwG9MfFflnyzndUQ8j78ZdSg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-POWDuTznzbIwlEXEvvXoPMS10y0WKXK790soe57tFRfvf4zBHyzE529HpZMqmDdwG9MfFflnyzndUQ8j78ZdSg== } + engines: { node: '>=18.0.0' } '@smithy/invalid-dependency@4.0.2': - resolution: {integrity: sha512-GatB4+2DTpgWPday+mnUkoumP54u/MDM/5u44KF9hIu8jF0uafZtQLcdfIKkIcUNuF/fBojpLEHZS/56JqPeXQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-GatB4+2DTpgWPday+mnUkoumP54u/MDM/5u44KF9hIu8jF0uafZtQLcdfIKkIcUNuF/fBojpLEHZS/56JqPeXQ== } + engines: { node: '>=18.0.0' } '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA== } + engines: { node: '>=14.0.0' } '@smithy/is-array-buffer@4.0.0': - resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw== } + engines: { node: '>=18.0.0' } '@smithy/md5-js@4.0.2': - resolution: {integrity: sha512-Hc0R8EiuVunUewCse2syVgA2AfSRco3LyAv07B/zCOMa+jpXI9ll+Q21Nc6FAlYPcpNcAXqBzMhNs1CD/pP2bA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Hc0R8EiuVunUewCse2syVgA2AfSRco3LyAv07B/zCOMa+jpXI9ll+Q21Nc6FAlYPcpNcAXqBzMhNs1CD/pP2bA== } + engines: { node: '>=18.0.0' } '@smithy/middleware-content-length@4.0.2': - resolution: {integrity: sha512-hAfEXm1zU+ELvucxqQ7I8SszwQ4znWMbNv6PLMndN83JJN41EPuS93AIyh2N+gJ6x8QFhzSO6b7q2e6oClDI8A==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-hAfEXm1zU+ELvucxqQ7I8SszwQ4znWMbNv6PLMndN83JJN41EPuS93AIyh2N+gJ6x8QFhzSO6b7q2e6oClDI8A== } + engines: { node: '>=18.0.0' } '@smithy/middleware-endpoint@4.1.3': - resolution: {integrity: sha512-w7fJjCSqdTVTs1o1O7SRZm+Umf6r/FzkdlO5OH6tboASeUeugnMgQAs7gnc2dXvJVJtEGrmrBgPZFPxq3wWyzw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-w7fJjCSqdTVTs1o1O7SRZm+Umf6r/FzkdlO5OH6tboASeUeugnMgQAs7gnc2dXvJVJtEGrmrBgPZFPxq3wWyzw== } + engines: { node: '>=18.0.0' } '@smithy/middleware-retry@4.1.4': - resolution: {integrity: sha512-QtWuD7bd7AAEFKvBmLQdOax25bXv4BACLQNWi3ddvpWwUUSAkAku9mzI+28jbjg48qw28lbzJ+YoYbbaXhLUjw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-QtWuD7bd7AAEFKvBmLQdOax25bXv4BACLQNWi3ddvpWwUUSAkAku9mzI+28jbjg48qw28lbzJ+YoYbbaXhLUjw== } + engines: { node: '>=18.0.0' } '@smithy/middleware-serde@4.0.3': - resolution: {integrity: sha512-rfgDVrgLEVMmMn0BI8O+8OVr6vXzjV7HZj57l0QxslhzbvVfikZbVfBVthjLHqib4BW44QhcIgJpvebHlRaC9A==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-rfgDVrgLEVMmMn0BI8O+8OVr6vXzjV7HZj57l0QxslhzbvVfikZbVfBVthjLHqib4BW44QhcIgJpvebHlRaC9A== } + engines: { node: '>=18.0.0' } '@smithy/middleware-stack@4.0.2': - resolution: {integrity: sha512-eSPVcuJJGVYrFYu2hEq8g8WWdJav3sdrI4o2c6z/rjnYDd3xH9j9E7deZQCzFn4QvGPouLngH3dQ+QVTxv5bOQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-eSPVcuJJGVYrFYu2hEq8g8WWdJav3sdrI4o2c6z/rjnYDd3xH9j9E7deZQCzFn4QvGPouLngH3dQ+QVTxv5bOQ== } + engines: { node: '>=18.0.0' } '@smithy/node-config-provider@4.1.0': - resolution: {integrity: sha512-gmPsv6L3ZRlBinv+vtSGUwfhTMh4+SgjbgGdX7bqYEs3Ys5RYVQtLuZ/WgZZdxn8QrDSUqLmTWunLM96WyM7UQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-gmPsv6L3ZRlBinv+vtSGUwfhTMh4+SgjbgGdX7bqYEs3Ys5RYVQtLuZ/WgZZdxn8QrDSUqLmTWunLM96WyM7UQ== } + engines: { node: '>=18.0.0' } '@smithy/node-http-handler@4.0.4': - resolution: {integrity: sha512-/mdqabuAT3o/ihBGjL94PUbTSPSRJ0eeVTdgADzow0wRJ0rN4A27EOrtlK56MYiO1fDvlO3jVTCxQtQmK9dZ1g==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-/mdqabuAT3o/ihBGjL94PUbTSPSRJ0eeVTdgADzow0wRJ0rN4A27EOrtlK56MYiO1fDvlO3jVTCxQtQmK9dZ1g== } + engines: { node: '>=18.0.0' } '@smithy/property-provider@4.0.2': - resolution: {integrity: sha512-wNRoQC1uISOuNc2s4hkOYwYllmiyrvVXWMtq+TysNRVQaHm4yoafYQyjN/goYZS+QbYlPIbb/QRjaUZMuzwQ7A==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-wNRoQC1uISOuNc2s4hkOYwYllmiyrvVXWMtq+TysNRVQaHm4yoafYQyjN/goYZS+QbYlPIbb/QRjaUZMuzwQ7A== } + engines: { node: '>=18.0.0' } '@smithy/protocol-http@5.1.0': - resolution: {integrity: sha512-KxAOL1nUNw2JTYrtviRRjEnykIDhxc84qMBzxvu1MUfQfHTuBlCG7PA6EdVwqpJjH7glw7FqQoFxUJSyBQgu7g==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-KxAOL1nUNw2JTYrtviRRjEnykIDhxc84qMBzxvu1MUfQfHTuBlCG7PA6EdVwqpJjH7glw7FqQoFxUJSyBQgu7g== } + engines: { node: '>=18.0.0' } '@smithy/querystring-builder@4.0.2': - resolution: {integrity: sha512-NTOs0FwHw1vimmQM4ebh+wFQvOwkEf/kQL6bSM1Lock+Bv4I89B3hGYoUEPkmvYPkDKyp5UdXJYu+PoTQ3T31Q==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-NTOs0FwHw1vimmQM4ebh+wFQvOwkEf/kQL6bSM1Lock+Bv4I89B3hGYoUEPkmvYPkDKyp5UdXJYu+PoTQ3T31Q== } + engines: { node: '>=18.0.0' } '@smithy/querystring-parser@4.0.2': - resolution: {integrity: sha512-v6w8wnmZcVXjfVLjxw8qF7OwESD9wnpjp0Dqry/Pod0/5vcEA3qxCr+BhbOHlxS8O+29eLpT3aagxXGwIoEk7Q==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-v6w8wnmZcVXjfVLjxw8qF7OwESD9wnpjp0Dqry/Pod0/5vcEA3qxCr+BhbOHlxS8O+29eLpT3aagxXGwIoEk7Q== } + engines: { node: '>=18.0.0' } '@smithy/service-error-classification@4.0.3': - resolution: {integrity: sha512-FTbcajmltovWMjj3tksDQdD23b2w6gH+A0DYA1Yz3iSpjDj8fmkwy62UnXcWMy4d5YoMoSyLFHMfkEVEzbiN8Q==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-FTbcajmltovWMjj3tksDQdD23b2w6gH+A0DYA1Yz3iSpjDj8fmkwy62UnXcWMy4d5YoMoSyLFHMfkEVEzbiN8Q== } + engines: { node: '>=18.0.0' } '@smithy/shared-ini-file-loader@4.0.2': - resolution: {integrity: sha512-J9/gTWBGVuFZ01oVA6vdb4DAjf1XbDhK6sLsu3OS9qmLrS6KB5ygpeHiM3miIbj1qgSJ96GYszXFWv6ErJ8QEw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-J9/gTWBGVuFZ01oVA6vdb4DAjf1XbDhK6sLsu3OS9qmLrS6KB5ygpeHiM3miIbj1qgSJ96GYszXFWv6ErJ8QEw== } + engines: { node: '>=18.0.0' } '@smithy/signature-v4@5.1.0': - resolution: {integrity: sha512-4t5WX60sL3zGJF/CtZsUQTs3UrZEDO2P7pEaElrekbLqkWPYkgqNW1oeiNYC6xXifBnT9dVBOnNQRvOE9riU9w==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-4t5WX60sL3zGJF/CtZsUQTs3UrZEDO2P7pEaElrekbLqkWPYkgqNW1oeiNYC6xXifBnT9dVBOnNQRvOE9riU9w== } + engines: { node: '>=18.0.0' } '@smithy/smithy-client@4.2.3': - resolution: {integrity: sha512-j/RRx6N007rJQ3qyjN4yuX9B0bxTn9ynDVxYQ43mcs7fluVJXmQGquy0TrWJfOPZcIikpY377GunZ2UK90GHYQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-j/RRx6N007rJQ3qyjN4yuX9B0bxTn9ynDVxYQ43mcs7fluVJXmQGquy0TrWJfOPZcIikpY377GunZ2UK90GHYQ== } + engines: { node: '>=18.0.0' } '@smithy/types@4.2.0': - resolution: {integrity: sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg== } + engines: { node: '>=18.0.0' } '@smithy/url-parser@4.0.2': - resolution: {integrity: sha512-Bm8n3j2ScqnT+kJaClSVCMeiSenK6jVAzZCNewsYWuZtnBehEz4r2qP0riZySZVfzB+03XZHJeqfmJDkeeSLiQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Bm8n3j2ScqnT+kJaClSVCMeiSenK6jVAzZCNewsYWuZtnBehEz4r2qP0riZySZVfzB+03XZHJeqfmJDkeeSLiQ== } + engines: { node: '>=18.0.0' } '@smithy/util-base64@4.0.0': - resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg== } + engines: { node: '>=18.0.0' } '@smithy/util-body-length-browser@4.0.0': - resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA== } + engines: { node: '>=18.0.0' } '@smithy/util-body-length-node@4.0.0': - resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg== } + engines: { node: '>=18.0.0' } '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA== } + engines: { node: '>=14.0.0' } '@smithy/util-buffer-from@4.0.0': - resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug== } + engines: { node: '>=18.0.0' } '@smithy/util-config-provider@4.0.0': - resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w== } + engines: { node: '>=18.0.0' } '@smithy/util-defaults-mode-browser@4.0.11': - resolution: {integrity: sha512-Z49QNUSKbEj7JVZqaSUZkTkexRciQBbmonJ8AMar4fA0S2kvVpgjeVyGXnZYWTFzkgEwStacjFq4cQKbaQ8AnQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Z49QNUSKbEj7JVZqaSUZkTkexRciQBbmonJ8AMar4fA0S2kvVpgjeVyGXnZYWTFzkgEwStacjFq4cQKbaQ8AnQ== } + engines: { node: '>=18.0.0' } '@smithy/util-defaults-mode-node@4.0.11': - resolution: {integrity: sha512-y9UYcXjz4ry5sDPX40Vy6224Cw2/dch+wET6giaRoeXpyh56DCUVxW+Mgc/gO2uczAKktWd4ZWs2LWcW+PHz3Q==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-y9UYcXjz4ry5sDPX40Vy6224Cw2/dch+wET6giaRoeXpyh56DCUVxW+Mgc/gO2uczAKktWd4ZWs2LWcW+PHz3Q== } + engines: { node: '>=18.0.0' } '@smithy/util-endpoints@3.0.3': - resolution: {integrity: sha512-284PZFhCMdudqq61/E67zJ3i10gCYrMBjXcMg3h048qI39gTXQCCeNZvtJhL4vrj9yMpJ/y9M+Ek7V0o5tak3w==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-284PZFhCMdudqq61/E67zJ3i10gCYrMBjXcMg3h048qI39gTXQCCeNZvtJhL4vrj9yMpJ/y9M+Ek7V0o5tak3w== } + engines: { node: '>=18.0.0' } '@smithy/util-hex-encoding@4.0.0': - resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw== } + engines: { node: '>=18.0.0' } '@smithy/util-middleware@4.0.2': - resolution: {integrity: sha512-6GDamTGLuBQVAEuQ4yDQ+ti/YINf/MEmIegrEeg7DdB/sld8BX1lqt9RRuIcABOhAGTA50bRbPzErez7SlDtDQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-6GDamTGLuBQVAEuQ4yDQ+ti/YINf/MEmIegrEeg7DdB/sld8BX1lqt9RRuIcABOhAGTA50bRbPzErez7SlDtDQ== } + engines: { node: '>=18.0.0' } '@smithy/util-retry@4.0.3': - resolution: {integrity: sha512-DPuYjZQDXmKr/sNvy9Spu8R/ESa2e22wXZzSAY6NkjOLj6spbIje/Aq8rT97iUMdDj0qHMRIe+bTxvlU74d9Ng==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-DPuYjZQDXmKr/sNvy9Spu8R/ESa2e22wXZzSAY6NkjOLj6spbIje/Aq8rT97iUMdDj0qHMRIe+bTxvlU74d9Ng== } + engines: { node: '>=18.0.0' } '@smithy/util-stream@4.2.0': - resolution: {integrity: sha512-Vj1TtwWnuWqdgQI6YTUF5hQ/0jmFiOYsc51CSMgj7QfyO+RF4EnT2HNjoviNlOOmgzgvf3f5yno+EiC4vrnaWQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Vj1TtwWnuWqdgQI6YTUF5hQ/0jmFiOYsc51CSMgj7QfyO+RF4EnT2HNjoviNlOOmgzgvf3f5yno+EiC4vrnaWQ== } + engines: { node: '>=18.0.0' } '@smithy/util-uri-escape@4.0.0': - resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg== } + engines: { node: '>=18.0.0' } '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A== } + engines: { node: '>=14.0.0' } '@smithy/util-utf8@4.0.0': - resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow== } + engines: { node: '>=18.0.0' } '@smithy/util-waiter@4.0.3': - resolution: {integrity: sha512-JtaY3FxmD+te+KSI2FJuEcfNC9T/DGGVf551babM7fAaXhjJUt7oSYurH1Devxd2+BOSUACCgt3buinx4UnmEA==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-JtaY3FxmD+te+KSI2FJuEcfNC9T/DGGVf551babM7fAaXhjJUt7oSYurH1Devxd2+BOSUACCgt3buinx4UnmEA== } + engines: { node: '>=18.0.0' } '@solana-program/address-lookup-table@0.10.0': - resolution: {integrity: sha512-lcp+IYwoFBODhg8vXsh5vpxweLxpSKqjAu8P1LyqQxgk2yqwYmJGA79YKa+lZvsQjP/c0rzIZYWIGxFMMes2zA==} + resolution: + { integrity: sha512-lcp+IYwoFBODhg8vXsh5vpxweLxpSKqjAu8P1LyqQxgk2yqwYmJGA79YKa+lZvsQjP/c0rzIZYWIGxFMMes2zA== } peerDependencies: '@solana/kit': ^5.0 '@solana-program/compute-budget@0.11.0': - resolution: {integrity: sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw==} + resolution: + { integrity: sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw== } peerDependencies: '@solana/kit': ^5.0 '@solana-program/memo@0.10.0': - resolution: {integrity: sha512-1FvQFenL3lzl5SpxhWV4QJCOLU/nvAOXGXjKjS7dprvG+0u971xoanApN7bM/a4NFZolp6S+lP2xVl6vTVIxbg==} + resolution: + { integrity: sha512-1FvQFenL3lzl5SpxhWV4QJCOLU/nvAOXGXjKjS7dprvG+0u971xoanApN7bM/a4NFZolp6S+lP2xVl6vTVIxbg== } peerDependencies: '@solana/kit': ^5.0 '@solana-program/system@0.10.0': - resolution: {integrity: sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==} + resolution: + { integrity: sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g== } peerDependencies: '@solana/kit': ^5.0 '@solana-program/token-2022@0.6.1': - resolution: {integrity: sha512-Ex02cruDMGfBMvZZCrggVR45vdQQSI/unHVpt/7HPt/IwFYB4eTlXtO8otYZyqV/ce5GqZ8S6uwyRf0zy6fdbA==} + resolution: + { integrity: sha512-Ex02cruDMGfBMvZZCrggVR45vdQQSI/unHVpt/7HPt/IwFYB4eTlXtO8otYZyqV/ce5GqZ8S6uwyRf0zy6fdbA== } peerDependencies: '@solana/kit': ^5.0 '@solana/sysvars': ^5.0 '@solana-program/token@0.9.0': - resolution: {integrity: sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==} + resolution: + { integrity: sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA== } peerDependencies: '@solana/kit': ^5.0 '@solana/accounts@5.0.0': - resolution: {integrity: sha512-0JzBdEobgp8NBdhhu+GgwNDh7e8KkHDsSTVZAnNQgvT3taOz0Mwv5E48MuEeDhW6DLFwWVAx/FO3pvibG/NGwA==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-0JzBdEobgp8NBdhhu+GgwNDh7e8KkHDsSTVZAnNQgvT3taOz0Mwv5E48MuEeDhW6DLFwWVAx/FO3pvibG/NGwA== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/accounts@5.5.1': - resolution: {integrity: sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -1957,14 +2346,16 @@ packages: optional: true '@solana/addresses@5.0.0': - resolution: {integrity: sha512-bVk+khc1ZZQHMri25csosM/ikuyPcB/CZidDM/ZMBX0CoJErpHJnmcID5mYOmv4/UHbqo2OANuEaGcFO0Q37sw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-bVk+khc1ZZQHMri25csosM/ikuyPcB/CZidDM/ZMBX0CoJErpHJnmcID5mYOmv4/UHbqo2OANuEaGcFO0Q37sw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/addresses@5.5.1': - resolution: {integrity: sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -1972,14 +2363,16 @@ packages: optional: true '@solana/assertions@5.0.0': - resolution: {integrity: sha512-2kIykk90kYciQW6bp+KaE6jRd1Y2CgHPeJxxlc5chQnjhoG6eiD8VXvocs6AvqPTht0p/SoEj9jH5tT4oG/bcg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-2kIykk90kYciQW6bp+KaE6jRd1Y2CgHPeJxxlc5chQnjhoG6eiD8VXvocs6AvqPTht0p/SoEj9jH5tT4oG/bcg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/assertions@5.5.1': - resolution: {integrity: sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -1987,38 +2380,45 @@ packages: optional: true '@solana/buffer-layout-utils@0.2.0': - resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==} - engines: {node: '>= 10'} + resolution: + { integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g== } + engines: { node: '>= 10' } '@solana/buffer-layout@4.0.1': - resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} - engines: {node: '>=5.10'} + resolution: + { integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA== } + engines: { node: '>=5.10' } '@solana/codecs-core@2.0.0-preview.4': - resolution: {integrity: sha512-A0VVuDDA5kNKZUinOqHxJQK32aKTucaVbvn31YenGzHX1gPqq+SOnFwgaEY6pq4XEopSmaK16w938ZQS8IvCnw==} + resolution: + { integrity: sha512-A0VVuDDA5kNKZUinOqHxJQK32aKTucaVbvn31YenGzHX1gPqq+SOnFwgaEY6pq4XEopSmaK16w938ZQS8IvCnw== } peerDependencies: typescript: '>=5' '@solana/codecs-core@2.0.0-rc.1': - resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==} + resolution: + { integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ== } peerDependencies: typescript: '>=5' '@solana/codecs-core@2.1.0': - resolution: {integrity: sha512-SR7pKtmJBg2mhmkel2NeHA1pz06QeQXdMv8WJoIR9m8F/hw80K/612uaYbwTt2nkK0jg/Qn/rNSd7EcJ4SBGjw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-SR7pKtmJBg2mhmkel2NeHA1pz06QeQXdMv8WJoIR9m8F/hw80K/612uaYbwTt2nkK0jg/Qn/rNSd7EcJ4SBGjw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5' '@solana/codecs-core@5.0.0': - resolution: {integrity: sha512-rCG2d8OaamVF2/J//YyCgDqNJpUytVVltw9C8mJtEz5c6Se/LR6BFuG8g4xeJswq/ab4RFk5/HFdgbvNjKgQjA==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-rCG2d8OaamVF2/J//YyCgDqNJpUytVVltw9C8mJtEz5c6Se/LR6BFuG8g4xeJswq/ab4RFk5/HFdgbvNjKgQjA== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/codecs-core@5.5.1': - resolution: {integrity: sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2026,24 +2426,28 @@ packages: optional: true '@solana/codecs-data-structures@2.0.0-preview.4': - resolution: {integrity: sha512-nt2k2eTeyzlI/ccutPcG36M/J8NAYfxBPI9h/nQjgJ+M+IgOKi31JV8StDDlG/1XvY0zyqugV3I0r3KAbZRJpA==} + resolution: + { integrity: sha512-nt2k2eTeyzlI/ccutPcG36M/J8NAYfxBPI9h/nQjgJ+M+IgOKi31JV8StDDlG/1XvY0zyqugV3I0r3KAbZRJpA== } peerDependencies: typescript: '>=5' '@solana/codecs-data-structures@2.0.0-rc.1': - resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==} + resolution: + { integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog== } peerDependencies: typescript: '>=5' '@solana/codecs-data-structures@5.0.0': - resolution: {integrity: sha512-y503Pqmv0LHcfcf0vQJGaxDvydQJbyCo8nK3nxn56EhFj5lBQ1NWb3WvTd83epigwuZurW2MhJARrpikfhQglQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-y503Pqmv0LHcfcf0vQJGaxDvydQJbyCo8nK3nxn56EhFj5lBQ1NWb3WvTd83epigwuZurW2MhJARrpikfhQglQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/codecs-data-structures@5.5.1': - resolution: {integrity: sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2051,30 +2455,35 @@ packages: optional: true '@solana/codecs-numbers@2.0.0-preview.4': - resolution: {integrity: sha512-Q061rLtMadsO7uxpguT+Z7G4UHnjQ6moVIxAQxR58nLxDPCC7MB1Pk106/Z7NDhDLHTcd18uO6DZ7ajHZEn2XQ==} + resolution: + { integrity: sha512-Q061rLtMadsO7uxpguT+Z7G4UHnjQ6moVIxAQxR58nLxDPCC7MB1Pk106/Z7NDhDLHTcd18uO6DZ7ajHZEn2XQ== } peerDependencies: typescript: '>=5' '@solana/codecs-numbers@2.0.0-rc.1': - resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==} + resolution: + { integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ== } peerDependencies: typescript: '>=5' '@solana/codecs-numbers@2.1.0': - resolution: {integrity: sha512-XMu4yw5iCgQnMKsxSWPPOrGgtaohmupN3eyAtYv3K3C/MJEc5V90h74k5B1GUCiHvcrdUDO9RclNjD9lgbjFag==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-XMu4yw5iCgQnMKsxSWPPOrGgtaohmupN3eyAtYv3K3C/MJEc5V90h74k5B1GUCiHvcrdUDO9RclNjD9lgbjFag== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5' '@solana/codecs-numbers@5.0.0': - resolution: {integrity: sha512-a2+skRLuUK02f/XFe4L0e1+wHCyfK25PkyseFps1v1l4pvevukFwth/EhSyrs6w5CsTJRVoR7MuE3E00PM4egw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-a2+skRLuUK02f/XFe4L0e1+wHCyfK25PkyseFps1v1l4pvevukFwth/EhSyrs6w5CsTJRVoR7MuE3E00PM4egw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/codecs-numbers@5.5.1': - resolution: {integrity: sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2082,27 +2491,31 @@ packages: optional: true '@solana/codecs-strings@2.0.0-preview.4': - resolution: {integrity: sha512-YDbsQePRWm+xnrfS64losSGRg8Wb76cjK1K6qfR8LPmdwIC3787x9uW5/E4icl/k+9nwgbIRXZ65lpF+ucZUnw==} + resolution: + { integrity: sha512-YDbsQePRWm+xnrfS64losSGRg8Wb76cjK1K6qfR8LPmdwIC3787x9uW5/E4icl/k+9nwgbIRXZ65lpF+ucZUnw== } peerDependencies: fastestsmallesttextencoderdecoder: ^1.0.22 typescript: '>=5' '@solana/codecs-strings@2.0.0-rc.1': - resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==} + resolution: + { integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g== } peerDependencies: fastestsmallesttextencoderdecoder: ^1.0.22 typescript: '>=5' '@solana/codecs-strings@5.0.0': - resolution: {integrity: sha512-ALkRwpV8bGR6qjAYw0YXZwp2YI4wzvKOJGmx04Ut8gMdbaUx7qOcJkhEQKI6ZVC3lAWSIS1N1wGccUZDwvfKxw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-ALkRwpV8bGR6qjAYw0YXZwp2YI4wzvKOJGmx04Ut8gMdbaUx7qOcJkhEQKI6ZVC3lAWSIS1N1wGccUZDwvfKxw== } + engines: { node: '>=20.18.0' } peerDependencies: fastestsmallesttextencoderdecoder: ^1.0.22 typescript: '>=5.3.3' '@solana/codecs-strings@5.5.1': - resolution: {integrity: sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A== } + engines: { node: '>=20.18.0' } peerDependencies: fastestsmallesttextencoderdecoder: ^1.0.22 typescript: ^5.0.0 @@ -2113,24 +2526,28 @@ packages: optional: true '@solana/codecs@2.0.0-preview.4': - resolution: {integrity: sha512-gLMupqI4i+G4uPi2SGF/Tc1aXcviZF2ybC81x7Q/fARamNSgNOCUUoSCg9nWu1Gid6+UhA7LH80sWI8XjKaRog==} + resolution: + { integrity: sha512-gLMupqI4i+G4uPi2SGF/Tc1aXcviZF2ybC81x7Q/fARamNSgNOCUUoSCg9nWu1Gid6+UhA7LH80sWI8XjKaRog== } peerDependencies: typescript: '>=5' '@solana/codecs@2.0.0-rc.1': - resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==} + resolution: + { integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ== } peerDependencies: typescript: '>=5' '@solana/codecs@5.0.0': - resolution: {integrity: sha512-KOw0gFUSBxIMDWLJ3AkVFkEci91dw0Rpx3C6y83Our7fSW+SEP8vRZklCElieYR85LHVB1QIEhoeHR7rc+Ifkw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-KOw0gFUSBxIMDWLJ3AkVFkEci91dw0Rpx3C6y83Our7fSW+SEP8vRZklCElieYR85LHVB1QIEhoeHR7rc+Ifkw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/codecs@5.5.1': - resolution: {integrity: sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2138,34 +2555,39 @@ packages: optional: true '@solana/errors@2.0.0-preview.4': - resolution: {integrity: sha512-kadtlbRv2LCWr8A9V22On15Us7Nn8BvqNaOB4hXsTB3O0fU40D1ru2l+cReqLcRPij4znqlRzW9Xi0m6J5DIhA==} + resolution: + { integrity: sha512-kadtlbRv2LCWr8A9V22On15Us7Nn8BvqNaOB4hXsTB3O0fU40D1ru2l+cReqLcRPij4znqlRzW9Xi0m6J5DIhA== } hasBin: true peerDependencies: typescript: '>=5' '@solana/errors@2.0.0-rc.1': - resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==} + resolution: + { integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ== } hasBin: true peerDependencies: typescript: '>=5' '@solana/errors@2.1.0': - resolution: {integrity: sha512-l+GxAv0Ar4d3c3PlZdA9G++wFYZREEbbRyAFP8+n8HSg0vudCuzogh/13io6hYuUhG/9Ve8ARZNamhV7UScKNw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-l+GxAv0Ar4d3c3PlZdA9G++wFYZREEbbRyAFP8+n8HSg0vudCuzogh/13io6hYuUhG/9Ve8ARZNamhV7UScKNw== } + engines: { node: '>=20.18.0' } hasBin: true peerDependencies: typescript: '>=5' '@solana/errors@5.0.0': - resolution: {integrity: sha512-gTuhzO6E+ydfAAzqmqdPcvFyJwAzFKKIrqtnZPpgAuomcPYu+HSo0tuwSM/cTX0djmHt+GoOsf/julph+nvs2w==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-gTuhzO6E+ydfAAzqmqdPcvFyJwAzFKKIrqtnZPpgAuomcPYu+HSo0tuwSM/cTX0djmHt+GoOsf/julph+nvs2w== } + engines: { node: '>=20.18.0' } hasBin: true peerDependencies: typescript: '>=5.3.3' '@solana/errors@5.5.1': - resolution: {integrity: sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg== } + engines: { node: '>=20.18.0' } hasBin: true peerDependencies: typescript: ^5.0.0 @@ -2174,50 +2596,58 @@ packages: optional: true '@solana/fast-stable-stringify@5.0.0': - resolution: {integrity: sha512-sGTbu7a4/olL+8EIOOJ7IZjzqOOpCJcK1UaVJ6015sRgo9vwGf4jg9KtXEYv5LVhLCTYmAb50L4BaIUcBph/Ig==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-sGTbu7a4/olL+8EIOOJ7IZjzqOOpCJcK1UaVJ6015sRgo9vwGf4jg9KtXEYv5LVhLCTYmAb50L4BaIUcBph/Ig== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/functional@5.0.0': - resolution: {integrity: sha512-UNBrpfzBL4dKD2iucjNnrkFbnjz5ZYDu2OvrIBAcCSQsxxgHMamUj1n3EDe6kl1us49YG1r05Ho8QLqNrbkVbw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-UNBrpfzBL4dKD2iucjNnrkFbnjz5ZYDu2OvrIBAcCSQsxxgHMamUj1n3EDe6kl1us49YG1r05Ho8QLqNrbkVbw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/instruction-plans@5.0.0': - resolution: {integrity: sha512-n9oFOMFUPYKEhsXzrXT97QBQ2WvOTar+5SFEj/IOtRuCn4gl2kh0369cjXZpFwUdE3tmKr1zfYFNwbtiNx5pvg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-n9oFOMFUPYKEhsXzrXT97QBQ2WvOTar+5SFEj/IOtRuCn4gl2kh0369cjXZpFwUdE3tmKr1zfYFNwbtiNx5pvg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/instructions@5.0.0': - resolution: {integrity: sha512-12dbrmwERT1o6NTr/Uvrjj/ZsiteSXoT5Gi+dnjIeRNHWg9H+gEFuFzJvTDVKlNg34CZ71xdvbVdbV0V8gKGvg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-12dbrmwERT1o6NTr/Uvrjj/ZsiteSXoT5Gi+dnjIeRNHWg9H+gEFuFzJvTDVKlNg34CZ71xdvbVdbV0V8gKGvg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/keys@5.0.0': - resolution: {integrity: sha512-kWkR7NslpTttk5i1BhBNCDtVQDkEtgkdsM3Jp9TGPk0GFjBjBwrQStw3vvwLe8itEIvRFGFZU6JHEk8HLS0WLQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-kWkR7NslpTttk5i1BhBNCDtVQDkEtgkdsM3Jp9TGPk0GFjBjBwrQStw3vvwLe8itEIvRFGFZU6JHEk8HLS0WLQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/kit@5.0.0': - resolution: {integrity: sha512-3ahtzmmMgU+1l2YMhQJSKKm14IdvCycOE/m4XNMu/4icBIptmBgZxrmgRpPHqBilBa+Krp/hBuTg4HWl9IAgWw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-3ahtzmmMgU+1l2YMhQJSKKm14IdvCycOE/m4XNMu/4icBIptmBgZxrmgRpPHqBilBa+Krp/hBuTg4HWl9IAgWw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/nominal-types@5.0.0': - resolution: {integrity: sha512-Qn7xH4UG2rDAv+wAyheP4jWvX3oQmbZ/woxFZwug7PaRLvyjUswGr38Hil+SjiQyFDo+un1UqWM9N9yusUeeZQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-Qn7xH4UG2rDAv+wAyheP4jWvX3oQmbZ/woxFZwug7PaRLvyjUswGr38Hil+SjiQyFDo+un1UqWM9N9yusUeeZQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/nominal-types@5.5.1': - resolution: {integrity: sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2225,24 +2655,28 @@ packages: optional: true '@solana/options@2.0.0-preview.4': - resolution: {integrity: sha512-tv2O/Frxql/wSe3jbzi5nVicIWIus/BftH+5ZR+r9r3FO0/htEllZS5Q9XdbmSboHu+St87584JXeDx3xm4jaA==} + resolution: + { integrity: sha512-tv2O/Frxql/wSe3jbzi5nVicIWIus/BftH+5ZR+r9r3FO0/htEllZS5Q9XdbmSboHu+St87584JXeDx3xm4jaA== } peerDependencies: typescript: '>=5' '@solana/options@2.0.0-rc.1': - resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==} + resolution: + { integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA== } peerDependencies: typescript: '>=5' '@solana/options@5.0.0': - resolution: {integrity: sha512-ezHVBFb9FXVSn8LUVRD2tLb6fejU0x8KtGEYyCYh0J0pQuXSITV0IQCjcEopvu/ZxWdXOJyzjvmymnhz90on5A==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-ezHVBFb9FXVSn8LUVRD2tLb6fejU0x8KtGEYyCYh0J0pQuXSITV0IQCjcEopvu/ZxWdXOJyzjvmymnhz90on5A== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/options@5.5.1': - resolution: {integrity: sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2250,38 +2684,44 @@ packages: optional: true '@solana/programs@5.0.0': - resolution: {integrity: sha512-BKOfBDrSUCJGZ+qKk2aFLu0nU9/84o6z/VDCJkLjaNNuTv8nOlSYq5flNzo1eyJmnpyW372qNvqqRN3AS23+FQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-BKOfBDrSUCJGZ+qKk2aFLu0nU9/84o6z/VDCJkLjaNNuTv8nOlSYq5flNzo1eyJmnpyW372qNvqqRN3AS23+FQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/promises@5.0.0': - resolution: {integrity: sha512-Qmg3UfYfWINEUvBQL3DkPOq34tTg5cfrkPlDtJmi8RVifsPqb6hksbKZGu7ASLZohxIDGmnYQY6oELI7Me+5yw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-Qmg3UfYfWINEUvBQL3DkPOq34tTg5cfrkPlDtJmi8RVifsPqb6hksbKZGu7ASLZohxIDGmnYQY6oELI7Me+5yw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-api@5.0.0': - resolution: {integrity: sha512-IJbZZnX2B1ldXPok1NhneXTYq9ZvdJbE5Pryr03pZTlPJaWGqDcZuQ14nwR4s6PoUUgdT+p87QlLZqLb8MusoQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-IJbZZnX2B1ldXPok1NhneXTYq9ZvdJbE5Pryr03pZTlPJaWGqDcZuQ14nwR4s6PoUUgdT+p87QlLZqLb8MusoQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-parsed-types@5.0.0': - resolution: {integrity: sha512-fU9uqlOYAaBqgk2qCl+ntenBm7wuSFBRbIO/rVjeBPd/qPCvNZU+qFET+ERLK6wbCTSz0MmdHqPn1V8KCMOvZQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-fU9uqlOYAaBqgk2qCl+ntenBm7wuSFBRbIO/rVjeBPd/qPCvNZU+qFET+ERLK6wbCTSz0MmdHqPn1V8KCMOvZQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-spec-types@5.0.0': - resolution: {integrity: sha512-B0P/ylXVaCG5oSIV+kB88s2qoW996D8iKhc7RyF0C/AyYvklF6kCwv0N9ZVrWp0ibjlQ8St290WbBHJyo7QZkA==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-B0P/ylXVaCG5oSIV+kB88s2qoW996D8iKhc7RyF0C/AyYvklF6kCwv0N9ZVrWp0ibjlQ8St290WbBHJyo7QZkA== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-spec-types@5.5.1': - resolution: {integrity: sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2289,14 +2729,16 @@ packages: optional: true '@solana/rpc-spec@5.0.0': - resolution: {integrity: sha512-1LD2SYEQ5bYhiBumznAPzymtxSX4nYLZd6u+FA0bAxNBVzHDvUUQzVSXHAoWROhlGrCyvtALTs9u0DIDlgZHCA==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-1LD2SYEQ5bYhiBumznAPzymtxSX4nYLZd6u+FA0bAxNBVzHDvUUQzVSXHAoWROhlGrCyvtALTs9u0DIDlgZHCA== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-spec@5.5.1': - resolution: {integrity: sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2304,51 +2746,59 @@ packages: optional: true '@solana/rpc-subscriptions-api@5.0.0': - resolution: {integrity: sha512-DGUn3C12swV2FConOlLFN14npIrCtnxehtMLjszMC7g6p/P6WNIz5uAgF7YcIkLBDV8uTeWhM0azmK+V8Qqhvg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-DGUn3C12swV2FConOlLFN14npIrCtnxehtMLjszMC7g6p/P6WNIz5uAgF7YcIkLBDV8uTeWhM0azmK+V8Qqhvg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-subscriptions-channel-websocket@5.0.0': - resolution: {integrity: sha512-vsYXyjVX/kExfpr91zfMKTmWKKFCM+dkhXQDAz5aEE7kAF3KSZDiOGeYvN8Rc85lbIt9QK6BLAT+NBMv4/N9Qg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-vsYXyjVX/kExfpr91zfMKTmWKKFCM+dkhXQDAz5aEE7kAF3KSZDiOGeYvN8Rc85lbIt9QK6BLAT+NBMv4/N9Qg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' ws: ^8.18.0 '@solana/rpc-subscriptions-spec@5.0.0': - resolution: {integrity: sha512-erRLvZMncwnciJP6I1SlAk0CyRGIgt83PyHWOVCRXENP9Q5dZbZ9pm4lar2yIp8EjIMnodGHsQWIlKc1hlCQlQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-erRLvZMncwnciJP6I1SlAk0CyRGIgt83PyHWOVCRXENP9Q5dZbZ9pm4lar2yIp8EjIMnodGHsQWIlKc1hlCQlQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-subscriptions@5.0.0': - resolution: {integrity: sha512-cziOSzom/bwFZXViR9J+MxDsdLMcfvrXGw5Icng7dYODFKuVqfsDrQoG8uekJc4fREnbPEM2U+u9YnYSYbFbww==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-cziOSzom/bwFZXViR9J+MxDsdLMcfvrXGw5Icng7dYODFKuVqfsDrQoG8uekJc4fREnbPEM2U+u9YnYSYbFbww== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-transformers@5.0.0': - resolution: {integrity: sha512-EMHhSgfF6/T4FfHbLaBP08SIj1ZAjxJr6WPNZMHLV7Cup8UfiB9TNV+bPQkum7JbVQNhUKzkKEEmyYqPfQoV9w==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-EMHhSgfF6/T4FfHbLaBP08SIj1ZAjxJr6WPNZMHLV7Cup8UfiB9TNV+bPQkum7JbVQNhUKzkKEEmyYqPfQoV9w== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-transport-http@5.0.0': - resolution: {integrity: sha512-RoIEvWp7yc7rIRzNkOyjLs2UQF0odIEMWj87dbD4Ir4hwTCGo/TSTfQF/8KDV2etdke3Fa1K+W1NkpG2POqWFg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-RoIEvWp7yc7rIRzNkOyjLs2UQF0odIEMWj87dbD4Ir4hwTCGo/TSTfQF/8KDV2etdke3Fa1K+W1NkpG2POqWFg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-types@5.0.0': - resolution: {integrity: sha512-JMbhwnV6nX4ezJv/KmaElOR0r/MZTKzKpaz6cv7FopLNuPrYCBrRCZKuM2XQh6gUbt9Mey08/KBOmOGmzTbL/g==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-JMbhwnV6nX4ezJv/KmaElOR0r/MZTKzKpaz6cv7FopLNuPrYCBrRCZKuM2XQh6gUbt9Mey08/KBOmOGmzTbL/g== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/rpc-types@5.5.1': - resolution: {integrity: sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2356,80 +2806,94 @@ packages: optional: true '@solana/rpc@5.0.0': - resolution: {integrity: sha512-Myx/ZBmMHkgh9Di3tLzc+vd30f+6YC1JXr9+YmIHKEeqN/+iTHkDJU2E/hGRLy8vTOBOU7+2466A+dLnSVuGkg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-Myx/ZBmMHkgh9Di3tLzc+vd30f+6YC1JXr9+YmIHKEeqN/+iTHkDJU2E/hGRLy8vTOBOU7+2466A+dLnSVuGkg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/signers@5.0.0': - resolution: {integrity: sha512-9Hw6HekSEzj5O7UBBFPrxk96W5e8tMI3n7KbW7/QiKBDpuvYw9WtnjOsWUE7LqQoc1P0JjGEsrmxE9raQBLvuQ==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-9Hw6HekSEzj5O7UBBFPrxk96W5e8tMI3n7KbW7/QiKBDpuvYw9WtnjOsWUE7LqQoc1P0JjGEsrmxE9raQBLvuQ== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/spl-token-group@0.0.5': - resolution: {integrity: sha512-CLJnWEcdoUBpQJfx9WEbX3h6nTdNiUzswfFdkABUik7HVwSNA98u5AYvBVK2H93d9PGMOHAak2lHW9xr+zAJGQ==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-CLJnWEcdoUBpQJfx9WEbX3h6nTdNiUzswfFdkABUik7HVwSNA98u5AYvBVK2H93d9PGMOHAak2lHW9xr+zAJGQ== } + engines: { node: '>=16' } peerDependencies: '@solana/web3.js': ^1.94.0 '@solana/spl-token-group@0.0.7': - resolution: {integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug== } + engines: { node: '>=16' } peerDependencies: '@solana/web3.js': ^1.95.3 '@solana/spl-token-metadata@0.1.6': - resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA== } + engines: { node: '>=16' } peerDependencies: '@solana/web3.js': ^1.95.3 '@solana/spl-token-registry@0.2.4574': - resolution: {integrity: sha512-JzlfZmke8Rxug20VT/VpI2XsXlsqMlcORIUivF+Yucj7tFi7A0dXG7h+2UnD0WaZJw8BrUz2ABNkUnv89vbv1A==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-JzlfZmke8Rxug20VT/VpI2XsXlsqMlcORIUivF+Yucj7tFi7A0dXG7h+2UnD0WaZJw8BrUz2ABNkUnv89vbv1A== } + engines: { node: '>=10' } '@solana/spl-token@0.2.0': - resolution: {integrity: sha512-RWcn31OXtdqIxmkzQfB2R+WpsJOVS6rKuvpxJFjvik2LyODd+WN58ZP3Rpjpro03fscGAkzlFuP3r42doRJgyQ==} - engines: {node: '>= 14'} + resolution: + { integrity: sha512-RWcn31OXtdqIxmkzQfB2R+WpsJOVS6rKuvpxJFjvik2LyODd+WN58ZP3Rpjpro03fscGAkzlFuP3r42doRJgyQ== } + engines: { node: '>= 14' } '@solana/spl-token@0.3.11': - resolution: {integrity: sha512-bvohO3rIMSVL24Pb+I4EYTJ6cL82eFpInEXD/I8K8upOGjpqHsKUoAempR/RnUlI1qSFNyFlWJfu6MNUgfbCQQ==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-bvohO3rIMSVL24Pb+I4EYTJ6cL82eFpInEXD/I8K8upOGjpqHsKUoAempR/RnUlI1qSFNyFlWJfu6MNUgfbCQQ== } + engines: { node: '>=16' } peerDependencies: '@solana/web3.js': ^1.88.0 '@solana/spl-token@0.4.14': - resolution: {integrity: sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA== } + engines: { node: '>=16' } peerDependencies: '@solana/web3.js': ^1.95.5 '@solana/spl-token@0.4.8': - resolution: {integrity: sha512-RO0JD9vPRi4LsAbMUdNbDJ5/cv2z11MGhtAvFeRzT4+hAGE/FUzRi0tkkWtuCfSIU3twC6CtmAihRp/+XXjWsA==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-RO0JD9vPRi4LsAbMUdNbDJ5/cv2z11MGhtAvFeRzT4+hAGE/FUzRi0tkkWtuCfSIU3twC6CtmAihRp/+XXjWsA== } + engines: { node: '>=16' } peerDependencies: '@solana/web3.js': ^1.94.0 '@solana/spl-type-length-value@0.1.0': - resolution: {integrity: sha512-JBMGB0oR4lPttOZ5XiUGyvylwLQjt1CPJa6qQ5oM+MBCndfjz2TKKkw0eATlLLcYmq1jBVsNlJ2cD6ns2GR7lA==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-JBMGB0oR4lPttOZ5XiUGyvylwLQjt1CPJa6qQ5oM+MBCndfjz2TKKkw0eATlLLcYmq1jBVsNlJ2cD6ns2GR7lA== } + engines: { node: '>=16' } '@solana/subscribable@5.0.0': - resolution: {integrity: sha512-C2TydIRRd5XUJ8asbARi67Sj/3DRLubWalnNoafBhDsrb88jsRVylntvwXgBw/+lwJdEPEsUnxvcdgdm+3lFlw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-C2TydIRRd5XUJ8asbARi67Sj/3DRLubWalnNoafBhDsrb88jsRVylntvwXgBw/+lwJdEPEsUnxvcdgdm+3lFlw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/sysvars@5.0.0': - resolution: {integrity: sha512-F/GEb2rS8mrgDd79lDPyu8za9jGE6cRlS4jHNeKCkvOCJxdKQbX34JIzx4kwzjtvk7O8/yrDHfGdpA8nBg/l4w==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-F/GEb2rS8mrgDd79lDPyu8za9jGE6cRlS4jHNeKCkvOCJxdKQbX34JIzx4kwzjtvk7O8/yrDHfGdpA8nBg/l4w== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/sysvars@5.5.1': - resolution: {integrity: sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -2437,226 +2901,294 @@ packages: optional: true '@solana/transaction-confirmation@5.0.0': - resolution: {integrity: sha512-LpusTopYIuQC8hBCloExkTr4Z5/zdp5f4IIbzD5XFeW3xXPZytS3H1IDMGk4bmLdZi9zQNA4lnNHKra5IncRbw==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-LpusTopYIuQC8hBCloExkTr4Z5/zdp5f4IIbzD5XFeW3xXPZytS3H1IDMGk4bmLdZi9zQNA4lnNHKra5IncRbw== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/transaction-messages@5.0.0': - resolution: {integrity: sha512-rJLe1wUGW5DovQFV0gjXHXnriPxTBgZ3TvGWnjCu2OIBU8mcQkQVJ7zzVZY2IAYlmJ6OSF9nvzhSt/ncPbkJPg==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-rJLe1wUGW5DovQFV0gjXHXnriPxTBgZ3TvGWnjCu2OIBU8mcQkQVJ7zzVZY2IAYlmJ6OSF9nvzhSt/ncPbkJPg== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/transactions@5.0.0': - resolution: {integrity: sha512-4TcsqH7JtgRKGGBIRRGz0n+tXu4h5TPPC49kkV0ygIndQaHW7FOZUYTwQ0epq0A5h9KYi+ClNbzF9xiuDbAD5Q==} - engines: {node: '>=20.18.0'} + resolution: + { integrity: sha512-4TcsqH7JtgRKGGBIRRGz0n+tXu4h5TPPC49kkV0ygIndQaHW7FOZUYTwQ0epq0A5h9KYi+ClNbzF9xiuDbAD5Q== } + engines: { node: '>=20.18.0' } peerDependencies: typescript: '>=5.3.3' '@solana/wallet-adapter-base@0.9.26': - resolution: {integrity: sha512-1RcmfesJ8bTT+zfg4w+Z+wisj11HR+vWwl/pS6v/zwQPe0LSzWDpkXRv9JuDSCuTcmlglEfjEqFAW+5EubK/Jg==} - engines: {node: '>=20'} + resolution: + { integrity: sha512-1RcmfesJ8bTT+zfg4w+Z+wisj11HR+vWwl/pS6v/zwQPe0LSzWDpkXRv9JuDSCuTcmlglEfjEqFAW+5EubK/Jg== } + engines: { node: '>=20' } peerDependencies: '@solana/web3.js': ^1.98.0 '@solana/wallet-standard-features@1.3.0': - resolution: {integrity: sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg== } + engines: { node: '>=16' } '@solana/web3.js@1.95.8': - resolution: {integrity: sha512-sBHzNh7dHMrmNS5xPD1d0Xa2QffW/RXaxu/OysRXBfwTp+LYqGGmMtCYYwrHPrN5rjAmJCsQRNAwv4FM0t3B6g==} + resolution: + { integrity: sha512-sBHzNh7dHMrmNS5xPD1d0Xa2QffW/RXaxu/OysRXBfwTp+LYqGGmMtCYYwrHPrN5rjAmJCsQRNAwv4FM0t3B6g== } '@solana/web3.js@1.98.2': - resolution: {integrity: sha512-BqVwEG+TaG2yCkBMbD3C4hdpustR4FpuUFRPUmqRZYYlPI9Hg4XMWxHWOWRzHE9Lkc9NDjzXFX7lDXSgzC7R1A==} + resolution: + { integrity: sha512-BqVwEG+TaG2yCkBMbD3C4hdpustR4FpuUFRPUmqRZYYlPI9Hg4XMWxHWOWRzHE9Lkc9NDjzXFX7lDXSgzC7R1A== } '@solflare-wallet/utl-sdk@1.4.0': - resolution: {integrity: sha512-0E3s+fXs5XMGBNrXGB4XSH4+sPgLanbBEVyz227KJyxSIgiRdQMcM2Yv/KdnMHNmhYoR/aPpH6TH115SIJqM0A==} + resolution: + { integrity: sha512-0E3s+fXs5XMGBNrXGB4XSH4+sPgLanbBEVyz227KJyxSIgiRdQMcM2Yv/KdnMHNmhYoR/aPpH6TH115SIJqM0A== } peerDependencies: '@solana/web3.js': '*' '@supercharge/promise-pool@2.4.0': - resolution: {integrity: sha512-O9CMipBlq5OObdt1uKJGIzm9cdjpPWfj+a+Zw9EgWKxaMNHKC7EU7X9taj3H0EGQNLOSq2jAcOa3EzxlfHsD6w==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-O9CMipBlq5OObdt1uKJGIzm9cdjpPWfj+a+Zw9EgWKxaMNHKC7EU7X9taj3H0EGQNLOSq2jAcOa3EzxlfHsD6w== } + engines: { node: '>=8' } '@swc/helpers@0.5.17': - resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + resolution: + { integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A== } '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + resolution: + { integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== } '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + resolution: + { integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== } '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + resolution: + { integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== } '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + resolution: + { integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== } '@tybys/wasm-util@0.9.0': - resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + resolution: + { integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw== } '@types/abstract-leveldown@7.2.5': - resolution: {integrity: sha512-/2B0nQF4UdupuxeKTJA2+Rj1D+uDemo6P4kMwKCpbfpnzeVaWSELTsAw4Lxn3VJD6APtRrZOCuYo+4nHUQfTfg==} + resolution: + { integrity: sha512-/2B0nQF4UdupuxeKTJA2+Rj1D+uDemo6P4kMwKCpbfpnzeVaWSELTsAw4Lxn3VJD6APtRrZOCuYo+4nHUQfTfg== } '@types/app-root-path@1.2.8': - resolution: {integrity: sha512-l12miuN6JXAi3yuADZNhRKbyN7IIyaUP9hFVZ/BbHhWYpBkHLbOaX2WkQoXGJyAgMcP9iZ0S9+tz/FN40VrwWQ==} + resolution: + { integrity: sha512-l12miuN6JXAi3yuADZNhRKbyN7IIyaUP9hFVZ/BbHhWYpBkHLbOaX2WkQoXGJyAgMcP9iZ0S9+tz/FN40VrwWQ== } '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + resolution: + { integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== } '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + resolution: + { integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== } '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + resolution: + { integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== } '@types/babel__traverse@7.20.7': - resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + resolution: + { integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng== } '@types/bn.js@5.1.6': - resolution: {integrity: sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w==} + resolution: + { integrity: sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w== } '@types/body-parser@1.19.5': - resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + resolution: + { integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== } '@types/brotli@1.3.4': - resolution: {integrity: sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==} + resolution: + { integrity: sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw== } '@types/bs58@4.0.4': - resolution: {integrity: sha512-0IEpMFXXQi2zXaXl9GJ3sRwQo0uEkD+yFOv+FnAU5lkPtcu6h61xb7jc2CFPEZ5BUOaiP13ThuGc9HD4R8lR5g==} + resolution: + { integrity: sha512-0IEpMFXXQi2zXaXl9GJ3sRwQo0uEkD+yFOv+FnAU5lkPtcu6h61xb7jc2CFPEZ5BUOaiP13ThuGc9HD4R8lR5g== } '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + resolution: + { integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== } '@types/encoding-down@5.0.5': - resolution: {integrity: sha512-HXlcVUJm2ITDgH34S1BjLMJhpfkXvEOEv+HS9KZweRl0LKlWNzVwFAj+N6pGExsRGsofhgqZP4ArQZJlWvBFDQ==} + resolution: + { integrity: sha512-HXlcVUJm2ITDgH34S1BjLMJhpfkXvEOEv+HS9KZweRl0LKlWNzVwFAj+N6pGExsRGsofhgqZP4ArQZJlWvBFDQ== } '@types/express-serve-static-core@4.19.6': - resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + resolution: + { integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A== } '@types/express@4.17.21': - resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + resolution: + { integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== } '@types/fs-extra@9.0.13': - resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + resolution: + { integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== } '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + resolution: + { integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== } '@types/http-errors@2.0.4': - resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + resolution: + { integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== } '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + resolution: + { integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== } '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + resolution: + { integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== } '@types/istanbul-reports@1.1.2': - resolution: {integrity: sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==} + resolution: + { integrity: sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw== } '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + resolution: + { integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== } '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + resolution: + { integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== } '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + resolution: + { integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== } '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + resolution: + { integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== } '@types/level-codec@9.0.4': - resolution: {integrity: sha512-N6v5EhpvF00Wv+1ixzqca9YD2wdK76JceSnUoiKfQh/vex+VFG852wzqohnlYf67nzKQoXeRzYd8W57fIkYCvg==} + resolution: + { integrity: sha512-N6v5EhpvF00Wv+1ixzqca9YD2wdK76JceSnUoiKfQh/vex+VFG852wzqohnlYf67nzKQoXeRzYd8W57fIkYCvg== } '@types/level-errors@3.0.2': - resolution: {integrity: sha512-gyZHbcQ2X5hNXf/9KS2qGEmgDe9EN2WDM3rJ5Ele467C0nA1sLhtmv1bZiPMDYfAYCfPWft0uQIaTvXbASSTRA==} + resolution: + { integrity: sha512-gyZHbcQ2X5hNXf/9KS2qGEmgDe9EN2WDM3rJ5Ele467C0nA1sLhtmv1bZiPMDYfAYCfPWft0uQIaTvXbASSTRA== } '@types/level@6.0.3': - resolution: {integrity: sha512-Wl95qkwvCZGwODK+AGALTJjeotfejR/hTNeErNmonmKCl/1moL/ZNVxHsPNIxXAUh9tIk6zEwBwx9erNVdUQOg==} + resolution: + { integrity: sha512-Wl95qkwvCZGwODK+AGALTJjeotfejR/hTNeErNmonmKCl/1moL/ZNVxHsPNIxXAUh9tIk6zEwBwx9erNVdUQOg== } '@types/levelup@5.1.5': - resolution: {integrity: sha512-Sm0jSj+LoncQ8BuZZJBjYitY5r9/V/Xd//vRjfgbQLWcQg2/iCm0HQqIOZ1KBE7QdNyAqMIG97mE3+t1GR0TIw==} + resolution: + { integrity: sha512-Sm0jSj+LoncQ8BuZZJBjYitY5r9/V/Xd//vRjfgbQLWcQg2/iCm0HQqIOZ1KBE7QdNyAqMIG97mE3+t1GR0TIw== } '@types/lru-cache@5.1.1': - resolution: {integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==} + resolution: + { integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw== } '@types/mathjs@9.4.2': - resolution: {integrity: sha512-GF5g1vJmvKdWIWsE53XX7EDAyCaZ9p6gaYm1xhlXn5JjrY/NJrOfJN3fBxS3wyZpVh3QqKoMkS2WjFwxWMHOTw==} + resolution: + { integrity: sha512-GF5g1vJmvKdWIWsE53XX7EDAyCaZ9p6gaYm1xhlXn5JjrY/NJrOfJN3fBxS3wyZpVh3QqKoMkS2WjFwxWMHOTw== } deprecated: This is a stub types definition. mathjs provides its own type definitions, so you do not need this installed. '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + resolution: + { integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== } '@types/minimist@1.2.5': - resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + resolution: + { integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== } '@types/node-fetch@2.6.12': - resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} + resolution: + { integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA== } '@types/node@11.11.6': - resolution: {integrity: sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==} + resolution: + { integrity: sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ== } '@types/node@12.20.55': - resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + resolution: + { integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== } '@types/node@15.14.9': - resolution: {integrity: sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==} + resolution: + { integrity: sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A== } '@types/pbkdf2@3.1.2': - resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==} + resolution: + { integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew== } '@types/qs@6.9.18': - resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} + resolution: + { integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA== } '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + resolution: + { integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== } '@types/secp256k1@4.0.6': - resolution: {integrity: sha512-hHxJU6PAEUn0TP4S/ZOzuTUvJWuZ6eIKeNKb5RBpODvSl6hp1Wrw4s7ATY50rklRCScUDpHzVA/DQdSjJ3UoYQ==} + resolution: + { integrity: sha512-hHxJU6PAEUn0TP4S/ZOzuTUvJWuZ6eIKeNKb5RBpODvSl6hp1Wrw4s7ATY50rklRCScUDpHzVA/DQdSjJ3UoYQ== } '@types/send@0.17.4': - resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + resolution: + { integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== } '@types/serve-static@1.15.7': - resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + resolution: + { integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== } '@types/stack-utils@1.0.1': - resolution: {integrity: sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==} + resolution: + { integrity: sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== } '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + resolution: + { integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== } '@types/triple-beam@1.3.5': - resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + resolution: + { integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== } '@types/uuid@8.3.4': - resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} + resolution: + { integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== } '@types/w3c-web-usb@1.0.10': - resolution: {integrity: sha512-CHgUI5kTc/QLMP8hODUHhge0D4vx+9UiAwIGiT0sTy/B2XpdX1U5rJt6JSISgr6ikRT7vxV9EVAFeYZqUnl1gQ==} + resolution: + { integrity: sha512-CHgUI5kTc/QLMP8hODUHhge0D4vx+9UiAwIGiT0sTy/B2XpdX1U5rJt6JSISgr6ikRT7vxV9EVAFeYZqUnl1gQ== } '@types/ws@7.4.7': - resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + resolution: + { integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww== } '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + resolution: + { integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== } '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + resolution: + { integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== } '@types/yargs@13.0.12': - resolution: {integrity: sha512-qCxJE1qgz2y0hA4pIxjBR+PelCH0U5CK1XJXFwCNqfmliatKp47UCXXE9Dyk1OXBDLvsCF57TqQEJaeLfDYEOQ==} + resolution: + { integrity: sha512-qCxJE1qgz2y0hA4pIxjBR+PelCH0U5CK1XJXFwCNqfmliatKp47UCXXE9Dyk1OXBDLvsCF57TqQEJaeLfDYEOQ== } '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + resolution: + { integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA== } '@typescript-eslint/eslint-plugin@7.18.0': - resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw== } + engines: { node: ^18.18.0 || >=20.0.0 } peerDependencies: '@typescript-eslint/parser': ^7.0.0 eslint: ^8.56.0 @@ -2666,8 +3198,9 @@ packages: optional: true '@typescript-eslint/parser@7.18.0': - resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg== } + engines: { node: ^18.18.0 || >=20.0.0 } peerDependencies: eslint: ^8.56.0 typescript: '*' @@ -2676,12 +3209,14 @@ packages: optional: true '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA== } + engines: { node: ^18.18.0 || >=20.0.0 } '@typescript-eslint/type-utils@7.18.0': - resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA== } + engines: { node: ^18.18.0 || >=20.0.0 } peerDependencies: eslint: ^8.56.0 typescript: '*' @@ -2690,12 +3225,14 @@ packages: optional: true '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ== } + engines: { node: ^18.18.0 || >=20.0.0 } '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA== } + engines: { node: ^18.18.0 || >=20.0.0 } peerDependencies: typescript: '*' peerDependenciesMeta: @@ -2703,40 +3240,49 @@ packages: optional: true '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw== } + engines: { node: ^18.18.0 || >=20.0.0 } peerDependencies: eslint: ^8.56.0 '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} + resolution: + { integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg== } + engines: { node: ^18.18.0 || >=20.0.0 } '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + resolution: + { integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== } deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@uniswap/default-token-list@11.19.0': - resolution: {integrity: sha512-H/YLpxeZUrzT4Ki8mi4k5UiadREiLHg7WUqCv0Qt/VkOjX2mIBhrxCj1Wh61/J7lK0XqOjksfpm6RG1+YErPoQ==} + resolution: + { integrity: sha512-H/YLpxeZUrzT4Ki8mi4k5UiadREiLHg7WUqCv0Qt/VkOjX2mIBhrxCj1Wh61/J7lK0XqOjksfpm6RG1+YErPoQ== } '@uniswap/lib@4.0.1-alpha': - resolution: {integrity: sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA== } + engines: { node: '>=10' } '@uniswap/permit2-sdk@1.4.0': - resolution: {integrity: sha512-l/aGhfhB93M76vXs4eB8QNwhELE6bs66kh7F1cyobaPtINaVpMmlJv+j3KmHeHwAZIsh7QXyYzhDxs07u0Pe4Q==} + resolution: + { integrity: sha512-l/aGhfhB93M76vXs4eB8QNwhELE6bs66kh7F1cyobaPtINaVpMmlJv+j3KmHeHwAZIsh7QXyYzhDxs07u0Pe4Q== } '@uniswap/router-sdk@2.11.0': - resolution: {integrity: sha512-DGx04vX+BbomBbeBwV8mHOxVG4sUmXZjQHZZYcCYCom00jt5ab6j+GRqiiLYf5TgaZHUk2DK+1CWuWuL3gLfoA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-DGx04vX+BbomBbeBwV8mHOxVG4sUmXZjQHZZYcCYCom00jt5ab6j+GRqiiLYf5TgaZHUk2DK+1CWuWuL3gLfoA== } + engines: { node: '>=18' } '@uniswap/sdk-core@7.18.0': - resolution: {integrity: sha512-Fy/U/yEweEYbGZi1cOpFBRTE0rwrueBesG8MPRlfhVXwutLn5OXZe5TjFSdIrjp7vqrNqgdmKIHIwg9k6MMSLQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-Fy/U/yEweEYbGZi1cOpFBRTE0rwrueBesG8MPRlfhVXwutLn5OXZe5TjFSdIrjp7vqrNqgdmKIHIwg9k6MMSLQ== } + engines: { node: '>=18' } '@uniswap/sdk@3.0.3': - resolution: {integrity: sha512-t4s8bvzaCFSiqD2qfXIm3rWhbdnXp+QjD3/mRaeVDHK7zWevs6RGEb1ohMiNgOCTZANvBayb4j8p+XFdnMBadQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-t4s8bvzaCFSiqD2qfXIm3rWhbdnXp+QjD3/mRaeVDHK7zWevs6RGEb1ohMiNgOCTZANvBayb4j8p+XFdnMBadQ== } + engines: { node: '>=10' } peerDependencies: '@ethersproject/address': ^5.0.0-beta '@ethersproject/contracts': ^5.0.0-beta @@ -2745,182 +3291,221 @@ packages: '@ethersproject/solidity': ^5.0.0-beta '@uniswap/smart-order-router@4.31.10': - resolution: {integrity: sha512-Bmg46KXDSfE1AAf1tPg+vDzMngVoGfzdohekhjJ1hIQG13tXcjEykqapQy+CrJUqXLL3lQvZj2uVEKfzCNH74Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-Bmg46KXDSfE1AAf1tPg+vDzMngVoGfzdohekhjJ1hIQG13tXcjEykqapQy+CrJUqXLL3lQvZj2uVEKfzCNH74Q== } + engines: { node: '>=10' } peerDependencies: jsbi: ^3.2.0 '@uniswap/swap-router-contracts@1.3.1': - resolution: {integrity: sha512-mh/YNbwKb7Mut96VuEtL+Z5bRe0xVIbjjiryn+iMMrK2sFKhR4duk/86mEz0UO5gSx4pQIw9G5276P5heY/7Rg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-mh/YNbwKb7Mut96VuEtL+Z5bRe0xVIbjjiryn+iMMrK2sFKhR4duk/86mEz0UO5gSx4pQIw9G5276P5heY/7Rg== } + engines: { node: '>=10' } deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@uniswap/token-lists@1.0.0-beta.34': - resolution: {integrity: sha512-Hc3TfrFaupg0M84e/Zv7BoF+fmMWDV15mZ5s8ZQt2qZxUcNw2GQW+L6L/2k74who31G+p1m3GRYbJpAo7d1pqA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-Hc3TfrFaupg0M84e/Zv7BoF+fmMWDV15mZ5s8ZQt2qZxUcNw2GQW+L6L/2k74who31G+p1m3GRYbJpAo7d1pqA== } + engines: { node: '>=10' } '@uniswap/universal-router-sdk@4.30.0': - resolution: {integrity: sha512-yO34+VMtqGScCxym0x9C3nTCk5sLef/dkOj5dvnLEs21fXp7k3Y54ih6zCWh2BqJnWcD8/tQdME1jTTy9RsZYA==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-yO34+VMtqGScCxym0x9C3nTCk5sLef/dkOj5dvnLEs21fXp7k3Y54ih6zCWh2BqJnWcD8/tQdME1jTTy9RsZYA== } + engines: { node: '>=14' } '@uniswap/universal-router-sdk@5.10.0': - resolution: {integrity: sha512-09ISYFJGfehv+FwJjLboLTGKEPlTmkLnBTR6uUhLpkevi8ah4iyfcnDxjNLwW8heJT2VKxE3fSHxb1fwf+uSMg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-09ISYFJGfehv+FwJjLboLTGKEPlTmkLnBTR6uUhLpkevi8ah4iyfcnDxjNLwW8heJT2VKxE3fSHxb1fwf+uSMg== } + engines: { node: '>=18' } '@uniswap/universal-router@1.6.0': - resolution: {integrity: sha512-Gt0b0rtMV1vSrgXY3vz5R1RCZENB+rOkbOidY9GvcXrK1MstSrQSOAc+FCr8FSgsDhmRAdft0lk5YUxtM9i9Lg==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-Gt0b0rtMV1vSrgXY3vz5R1RCZENB+rOkbOidY9GvcXrK1MstSrQSOAc+FCr8FSgsDhmRAdft0lk5YUxtM9i9Lg== } + engines: { node: '>=14' } '@uniswap/universal-router@2.1.0': - resolution: {integrity: sha512-rt18RUsZd9xDfyVfIONJo+TEQ8w+olOYxu9+A1g4Thil1R7IMa+8mnyVQjdLPK2REhejScDwjYbOGpeaAce0hg==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-rt18RUsZd9xDfyVfIONJo+TEQ8w+olOYxu9+A1g4Thil1R7IMa+8mnyVQjdLPK2REhejScDwjYbOGpeaAce0hg== } + engines: { node: '>=14' } '@uniswap/v2-core@1.0.1': - resolution: {integrity: sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q== } + engines: { node: '>=10' } '@uniswap/v2-sdk@4.21.0': - resolution: {integrity: sha512-enr8SZRwrSgc/TYGJv4YS5f9oV049HwZJUF/xy1ZS4Vf55ie282SwkPZNMjA7IV+cYPtl6HKermaebXlneDEbg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-enr8SZRwrSgc/TYGJv4YS5f9oV049HwZJUF/xy1ZS4Vf55ie282SwkPZNMjA7IV+cYPtl6HKermaebXlneDEbg== } + engines: { node: '>=18' } '@uniswap/v3-core@1.0.0': - resolution: {integrity: sha512-kSC4djMGKMHj7sLMYVnn61k9nu+lHjMIxgg9CDQT+s2QYLoA56GbSK9Oxr+qJXzzygbkrmuY6cwgP6cW2JXPFA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-kSC4djMGKMHj7sLMYVnn61k9nu+lHjMIxgg9CDQT+s2QYLoA56GbSK9Oxr+qJXzzygbkrmuY6cwgP6cW2JXPFA== } + engines: { node: '>=10' } '@uniswap/v3-core@1.0.1': - resolution: {integrity: sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ== } + engines: { node: '>=10' } '@uniswap/v3-periphery@1.4.4': - resolution: {integrity: sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw== } + engines: { node: '>=10' } '@uniswap/v3-sdk@3.27.0': - resolution: {integrity: sha512-BRgb9nWuxptXJmuQrax9XyqcuOMEuWsUjDSyus0UvOavzijbOu8jh3DWptg/15D7oL67Xmz5zvQaSPbLIL1cpA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-BRgb9nWuxptXJmuQrax9XyqcuOMEuWsUjDSyus0UvOavzijbOu8jh3DWptg/15D7oL67Xmz5zvQaSPbLIL1cpA== } + engines: { node: '>=10' } '@uniswap/v3-sdk@3.31.0': - resolution: {integrity: sha512-7M9LXSO+XhB/Y+9SwONHaiJqYSTlHMqOSqeWGpSNAQOZZd5P7z/FXyWmvL1OogVsghW0mMAwvNc+YHQvrI7ksg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-7M9LXSO+XhB/Y+9SwONHaiJqYSTlHMqOSqeWGpSNAQOZZd5P7z/FXyWmvL1OogVsghW0mMAwvNc+YHQvrI7ksg== } + engines: { node: '>=18' } '@uniswap/v3-staker@1.0.0': - resolution: {integrity: sha512-JV0Qc46Px5alvg6YWd+UIaGH9lDuYG/Js7ngxPit1SPaIP30AlVer1UYB7BRYeUVVxE+byUyIeN5jeQ7LLDjIw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-JV0Qc46Px5alvg6YWd+UIaGH9lDuYG/Js7ngxPit1SPaIP30AlVer1UYB7BRYeUVVxE+byUyIeN5jeQ7LLDjIw== } + engines: { node: '>=10' } deprecated: Please upgrade to 1.0.1 '@uniswap/v4-sdk@1.27.0': - resolution: {integrity: sha512-htQFiON12RR4BipyVdzr4XklYjy756bqruBLA8b4n9Wn7QAWemYrDGbnCvmk1xvzvtc4t9WggDlbRjZ5ON34+g==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-htQFiON12RR4BipyVdzr4XklYjy756bqruBLA8b4n9Wn7QAWemYrDGbnCvmk1xvzvtc4t9WggDlbRjZ5ON34+g== } + engines: { node: '>=14' } '@uniswap/v4-sdk@2.3.0': - resolution: {integrity: sha512-aMsDxVFjnwxjWeX8lXJy+4SRPgllfEU05SJ6CRsiPOeqBMd9RHxvb0RXF4q42wNG/iKoUVg9O2q6s5uoRDXPrQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-aMsDxVFjnwxjWeX8lXJy+4SRPgllfEU05SJ6CRsiPOeqBMd9RHxvb0RXF4q42wNG/iKoUVg9O2q6s5uoRDXPrQ== } + engines: { node: '>=18' } '@unrs/resolver-binding-android-arm-eabi@1.9.2': - resolution: {integrity: sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==} + resolution: + { integrity: sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A== } cpu: [arm] os: [android] '@unrs/resolver-binding-android-arm64@1.9.2': - resolution: {integrity: sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==} + resolution: + { integrity: sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q== } cpu: [arm64] os: [android] '@unrs/resolver-binding-darwin-arm64@1.9.2': - resolution: {integrity: sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==} + resolution: + { integrity: sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ== } cpu: [arm64] os: [darwin] '@unrs/resolver-binding-darwin-x64@1.9.2': - resolution: {integrity: sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==} + resolution: + { integrity: sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ== } cpu: [x64] os: [darwin] '@unrs/resolver-binding-freebsd-x64@1.9.2': - resolution: {integrity: sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==} + resolution: + { integrity: sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw== } cpu: [x64] os: [freebsd] '@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2': - resolution: {integrity: sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==} + resolution: + { integrity: sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw== } cpu: [arm] os: [linux] '@unrs/resolver-binding-linux-arm-musleabihf@1.9.2': - resolution: {integrity: sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==} + resolution: + { integrity: sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg== } cpu: [arm] os: [linux] '@unrs/resolver-binding-linux-arm64-gnu@1.9.2': - resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==} + resolution: + { integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA== } cpu: [arm64] os: [linux] '@unrs/resolver-binding-linux-arm64-musl@1.9.2': - resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==} + resolution: + { integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA== } cpu: [arm64] os: [linux] '@unrs/resolver-binding-linux-ppc64-gnu@1.9.2': - resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==} + resolution: + { integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw== } cpu: [ppc64] os: [linux] '@unrs/resolver-binding-linux-riscv64-gnu@1.9.2': - resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==} + resolution: + { integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ== } cpu: [riscv64] os: [linux] '@unrs/resolver-binding-linux-riscv64-musl@1.9.2': - resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==} + resolution: + { integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg== } cpu: [riscv64] os: [linux] '@unrs/resolver-binding-linux-s390x-gnu@1.9.2': - resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==} + resolution: + { integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ== } cpu: [s390x] os: [linux] '@unrs/resolver-binding-linux-x64-gnu@1.9.2': - resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==} + resolution: + { integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w== } cpu: [x64] os: [linux] '@unrs/resolver-binding-linux-x64-musl@1.9.2': - resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==} + resolution: + { integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw== } cpu: [x64] os: [linux] '@unrs/resolver-binding-wasm32-wasi@1.9.2': - resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ== } + engines: { node: '>=14.0.0' } cpu: [wasm32] '@unrs/resolver-binding-win32-arm64-msvc@1.9.2': - resolution: {integrity: sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==} + resolution: + { integrity: sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw== } cpu: [arm64] os: [win32] '@unrs/resolver-binding-win32-ia32-msvc@1.9.2': - resolution: {integrity: sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==} + resolution: + { integrity: sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA== } cpu: [ia32] os: [win32] '@unrs/resolver-binding-win32-x64-msvc@1.9.2': - resolution: {integrity: sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==} + resolution: + { integrity: sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg== } cpu: [x64] os: [win32] '@wallet-standard/base@1.1.0': - resolution: {integrity: sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ== } + engines: { node: '>=16' } '@wallet-standard/features@1.1.0': - resolution: {integrity: sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg== } + engines: { node: '>=16' } abitype@1.0.8: - resolution: {integrity: sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==} + resolution: + { integrity: sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg== } peerDependencies: typescript: '>=5.0.4' zod: ^3 >=3.22.0 @@ -2931,7 +3516,8 @@ packages: optional: true abitype@1.0.9: - resolution: {integrity: sha512-oN0S++TQmlwWuB+rkA6aiEefLv3SP+2l/tC5mux/TLj6qdA6rF15Vbpex4fHovLsMkwLwTIRj8/Q8vXCS3GfOg==} + resolution: + { integrity: sha512-oN0S++TQmlwWuB+rkA6aiEefLv3SP+2l/tC5mux/TLj6qdA6rF15Vbpex4fHovLsMkwLwTIRj8/Q8vXCS3GfOg== } peerDependencies: typescript: '>=5.0.4' zod: ^3 >=3.22.0 @@ -2942,55 +3528,68 @@ packages: optional: true abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} + resolution: + { integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== } + engines: { node: '>=6.5' } abstract-level@1.0.4: - resolution: {integrity: sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg== } + engines: { node: '>=12' } abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + resolution: + { integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== } accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== } + engines: { node: '>= 0.6' } acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + resolution: + { integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== } peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} + resolution: + { integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== } + engines: { node: '>=0.4.0' } acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} - engines: {node: '>=0.4.0'} + resolution: + { integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== } + engines: { node: '>=0.4.0' } hasBin: true adm-zip@0.4.16: - resolution: {integrity: sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==} - engines: {node: '>=0.3.0'} + resolution: + { integrity: sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg== } + engines: { node: '>=0.3.0' } aes-js@3.0.0: - resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==} + resolution: + { integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw== } agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} + resolution: + { integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== } + engines: { node: '>= 6.0.0' } agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} + resolution: + { integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== } + engines: { node: '>= 8.0.0' } aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== } + engines: { node: '>=8' } ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + resolution: + { integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== } peerDependencies: ajv: ^8.0.0 peerDependenciesMeta: @@ -2998,7 +3597,8 @@ packages: optional: true ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + resolution: + { integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== } peerDependencies: ajv: ^8.0.0 peerDependenciesMeta: @@ -3006,755 +3606,962 @@ packages: optional: true ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + resolution: + { integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== } ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + resolution: + { integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== } algo-msgpack-with-bigint@2.1.1: - resolution: {integrity: sha512-F1tGh056XczEaEAqu7s+hlZUDWwOBT70Eq0lfMpBP2YguSQVyxRbprLq5rELXKQOyOaixTWYhMeMQMzP0U5FoQ==} - engines: {node: '>= 10'} + resolution: + { integrity: sha512-F1tGh056XczEaEAqu7s+hlZUDWwOBT70Eq0lfMpBP2YguSQVyxRbprLq5rELXKQOyOaixTWYhMeMQMzP0U5FoQ== } + engines: { node: '>= 10' } algosdk@1.24.1: - resolution: {integrity: sha512-9moZxdqeJ6GdE4N6fA/GlUP4LrbLZMYcYkt141J4Ss68OfEgH9qW0wBuZ3ZOKEx/xjc5bg7mLP2Gjg7nwrkmww==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-9moZxdqeJ6GdE4N6fA/GlUP4LrbLZMYcYkt141J4Ss68OfEgH9qW0wBuZ3ZOKEx/xjc5bg7mLP2Gjg7nwrkmww== } + engines: { node: '>=14.0.0' } ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + resolution: + { integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== } ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} + 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'} + resolution: + { integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== } + engines: { node: '>=8' } ansi-escapes@7.0.0: - resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw== } + engines: { node: '>=18' } ansi-regex@3.0.1: - resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== } + engines: { node: '>=4' } ansi-regex@4.1.1: - resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== } + engines: { node: '>=6' } ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== } + engines: { node: '>=8' } ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== } + engines: { node: '>=12' } ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== } + engines: { node: '>=4' } ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== } + engines: { node: '>=8' } ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== } + engines: { node: '>=10' } ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== } + engines: { node: '>=12' } ansicolors@0.3.2: - resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==} + resolution: + { integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg== } anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== } + engines: { node: '>= 8' } app-root-path@3.1.0: - resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} - engines: {node: '>= 6.0.0'} + resolution: + { integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA== } + engines: { node: '>= 6.0.0' } arbundles@0.6.23: - resolution: {integrity: sha512-+gr93F3fivN+6dhiImT6BQNaXz4oECPn2GYjCZjS2yEoq7hM78FRvVp6kQyjEdhnuBFQr/q4oS/nkjnQlHdj9Q==} + resolution: + { integrity: sha512-+gr93F3fivN+6dhiImT6BQNaXz4oECPn2GYjCZjS2yEoq7hM78FRvVp6kQyjEdhnuBFQr/q4oS/nkjnQlHdj9Q== } arconnect@0.4.2: - resolution: {integrity: sha512-Jkpd4QL3TVqnd3U683gzXmZUVqBUy17DdJDuL/3D9rkysLgX6ymJ2e+sR+xyZF5Rh42CBqDXWNMmCjBXeP7Gbw==} + resolution: + { integrity: sha512-Jkpd4QL3TVqnd3U683gzXmZUVqBUy17DdJDuL/3D9rkysLgX6ymJ2e+sR+xyZF5Rh42CBqDXWNMmCjBXeP7Gbw== } arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + resolution: + { integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== } arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + resolution: + { integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== } argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + resolution: + { integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== } argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + resolution: + { integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== } arr-diff@4.0.0: - resolution: {integrity: sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA== } + engines: { node: '>=0.10.0' } arr-flatten@1.1.0: - resolution: {integrity: sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== } + engines: { node: '>=0.10.0' } arr-union@3.1.0: - resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== } + engines: { node: '>=0.10.0' } array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== } + engines: { node: '>= 0.4' } array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + resolution: + { integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== } array-includes@3.1.8: - resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== } + engines: { node: '>= 0.4' } array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== } + engines: { node: '>=8' } array-unique@0.3.2: - resolution: {integrity: sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ== } + engines: { node: '>=0.10.0' } array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== } + engines: { node: '>= 0.4' } array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== } + engines: { node: '>= 0.4' } array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== } + engines: { node: '>= 0.4' } arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== } + engines: { node: '>= 0.4' } arweave-stream-tx@1.2.2: - resolution: {integrity: sha512-bNt9rj0hbAEzoUZEF2s6WJbIz8nasZlZpxIw03Xm8fzb9gRiiZlZGW3lxQLjfc9Z0VRUWDzwtqoYeEoB/JDToQ==} + resolution: + { integrity: sha512-bNt9rj0hbAEzoUZEF2s6WJbIz8nasZlZpxIw03Xm8fzb9gRiiZlZGW3lxQLjfc9Z0VRUWDzwtqoYeEoB/JDToQ== } peerDependencies: arweave: ^1.10.0 arweave@1.15.7: - resolution: {integrity: sha512-F+Y4iWU1qea9IsKQ/YNmLsY4DHQVsaJBuhEbFxQn9cfGHOmtXE+bwo14oY8xqymsqSNf/e1PeIfLk7G7qN/hVA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-F+Y4iWU1qea9IsKQ/YNmLsY4DHQVsaJBuhEbFxQn9cfGHOmtXE+bwo14oY8xqymsqSNf/e1PeIfLk7G7qN/hVA== } + engines: { node: '>=18' } asn1.js@5.4.1: - resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + resolution: + { integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== } assert@2.1.0: - resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + resolution: + { integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== } assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + resolution: + { integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== } assign-symbols@1.0.0: - resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw== } + engines: { node: '>=0.10.0' } astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== } + engines: { node: '>=8' } async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== } + engines: { node: '>= 0.4' } async-retry@1.3.3: - resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + resolution: + { integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw== } async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + resolution: + { integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== } asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + resolution: + { integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== } atob@2.1.2: - resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} - engines: {node: '>= 4.5.0'} + resolution: + { integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== } + engines: { node: '>= 4.5.0' } hasBin: true atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} + resolution: + { integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== } + engines: { node: '>=8.0.0' } available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== } + engines: { node: '>= 0.4' } avsc@https://codeload.github.com/Irys-xyz/avsc/tar.gz/a730cc8018b79e114b6a3381bbb57760a24c6cef: - resolution: {tarball: https://codeload.github.com/Irys-xyz/avsc/tar.gz/a730cc8018b79e114b6a3381bbb57760a24c6cef} + resolution: { tarball: https://codeload.github.com/Irys-xyz/avsc/tar.gz/a730cc8018b79e114b6a3381bbb57760a24c6cef } version: 5.4.7 - engines: {node: '>=0.11'} + engines: { node: '>=0.11' } avvio@8.4.0: - resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==} + resolution: + { integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA== } await-timeout@1.1.1: - resolution: {integrity: sha512-gsDXAS6XVc4Jt+7S92MPX6Noq69bdeXUPEaXd8dk3+yVr629LTDLxNt4j1ycBbrU+AStK2PhKIyNIM+xzWMVOQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-gsDXAS6XVc4Jt+7S92MPX6Noq69bdeXUPEaXd8dk3+yVr629LTDLxNt4j1ycBbrU+AStK2PhKIyNIM+xzWMVOQ== } + engines: { node: '>=6' } axios@1.12.0: - resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} + resolution: + { integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg== } babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } peerDependencies: '@babel/core': ^7.8.0 babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== } + engines: { node: '>=8' } babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } babel-preset-current-node-syntax@1.1.0: - resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==} + resolution: + { integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw== } peerDependencies: '@babel/core': ^7.0.0 babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } peerDependencies: '@babel/core': ^7.0.0 balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + resolution: + { integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== } base-x@3.0.11: - resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} + resolution: + { integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA== } base-x@4.0.1: - resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} + resolution: + { integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw== } base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + resolution: + { integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== } base64-sol@1.0.1: - resolution: {integrity: sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg==} + resolution: + { integrity: sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg== } base64url@3.0.1: - resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== } + engines: { node: '>=6.0.0' } base@0.11.2: - resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== } + engines: { node: '>=0.10.0' } bech32@1.1.4: - resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==} + resolution: + { integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== } big.js@5.2.2: - resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + resolution: + { integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== } big.js@6.2.2: - resolution: {integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==} + resolution: + { integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ== } bigint-buffer@1.1.5: - resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==} - engines: {node: '>= 10.0.0'} + resolution: + { integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA== } + engines: { node: '>= 10.0.0' } bignumber.js@9.3.0: - resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + resolution: + { integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA== } binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== } + engines: { node: '>=8' } bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + resolution: + { integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== } bip32-path@0.4.2: - resolution: {integrity: sha512-ZBMCELjJfcNMkz5bDuJ1WrYvjlhEF5k6mQ8vUr4N7MbVRsXei7ZOg8VhhwMfNiW68NWmLkgkc6WvTickrLGprQ==} + resolution: + { integrity: sha512-ZBMCELjJfcNMkz5bDuJ1WrYvjlhEF5k6mQ8vUr4N7MbVRsXei7ZOg8VhhwMfNiW68NWmLkgkc6WvTickrLGprQ== } bip39-light@1.0.7: - resolution: {integrity: sha512-WDTmLRQUsiioBdTs9BmSEmkJza+8xfJmptsNJjxnoq3EydSa/ZBXT6rm66KoT3PJIRYMnhSKNR7S9YL1l7R40Q==} + resolution: + { integrity: sha512-WDTmLRQUsiioBdTs9BmSEmkJza+8xfJmptsNJjxnoq3EydSa/ZBXT6rm66KoT3PJIRYMnhSKNR7S9YL1l7R40Q== } bip39@3.0.2: - resolution: {integrity: sha512-J4E1r2N0tUylTKt07ibXvhpT2c5pyAFgvuA5q1H9uDy6dEGpjV8jmymh3MTYJDLCNbIVClSB9FbND49I6N24MQ==} + resolution: + { integrity: sha512-J4E1r2N0tUylTKt07ibXvhpT2c5pyAFgvuA5q1H9uDy6dEGpjV8jmymh3MTYJDLCNbIVClSB9FbND49I6N24MQ== } bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + resolution: + { integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== } blakejs@1.2.1: - resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} + resolution: + { integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ== } bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + resolution: + { integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== } bn.js@5.2.1: - resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==} + resolution: + { integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== } body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + resolution: + { integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== } + engines: { node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16 } borsh@0.6.0: - resolution: {integrity: sha512-sl5k89ViqsThXQpYa9XDtz1sBl3l1lI313cFUY1HKr+wvMILnb+58xpkqTNrYbelh99dY7K8usxoCusQmqix9Q==} + resolution: + { integrity: sha512-sl5k89ViqsThXQpYa9XDtz1sBl3l1lI313cFUY1HKr+wvMILnb+58xpkqTNrYbelh99dY7K8usxoCusQmqix9Q== } borsh@0.7.0: - resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + resolution: + { integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA== } bowser@2.11.0: - resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + resolution: + { integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== } boxen@5.1.2: - resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== } + engines: { node: '>=10' } brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + resolution: + { integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== } brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + resolution: + { integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== } braces@2.3.2: - resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== } + engines: { node: '>=0.10.0' } braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== } + engines: { node: '>=8' } brorand@1.1.0: - resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + resolution: + { integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== } brotli@1.3.2: - resolution: {integrity: sha512-K0HNa0RRpUpcF8yS4yNSd6vmkrvA+wRd+symIcwhfqGLAi7YgGlKfO4oDYVgiahiLGNviO9uY7Zlb1MCPeTmSA==} + resolution: + { integrity: sha512-K0HNa0RRpUpcF8yS4yNSd6vmkrvA+wRd+symIcwhfqGLAi7YgGlKfO4oDYVgiahiLGNviO9uY7Zlb1MCPeTmSA== } browser-level@1.0.1: - resolution: {integrity: sha512-XECYKJ+Dbzw0lbydyQuJzwNXtOpbMSq737qxJN11sIRTErOMShvDpbzTlgju7orJKvx4epULolZAuJGLzCmWRQ==} + resolution: + { integrity: sha512-XECYKJ+Dbzw0lbydyQuJzwNXtOpbMSq737qxJN11sIRTErOMShvDpbzTlgju7orJKvx4epULolZAuJGLzCmWRQ== } browser-stdout@1.3.1: - resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + resolution: + { integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== } browserify-aes@1.2.0: - resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + resolution: + { integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== } browserslist@4.24.5: - resolution: {integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + resolution: + { integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw== } + engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== } + engines: { node: '>= 6' } bs58@4.0.1: - resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + resolution: + { integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw== } bs58@5.0.0: - resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + resolution: + { integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ== } bs58check@2.1.2: - resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} + resolution: + { integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== } bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + resolution: + { integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== } buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + resolution: + { integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== } buffer-layout@1.2.2: - resolution: {integrity: sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA==} - engines: {node: '>=4.5'} + resolution: + { integrity: sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA== } + engines: { node: '>=4.5' } buffer-reverse@1.0.1: - resolution: {integrity: sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==} + resolution: + { integrity: sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg== } buffer-xor@1.0.3: - resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} + resolution: + { integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== } buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + resolution: + { integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== } buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + resolution: + { integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== } bufferutil@4.0.9: - resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} - engines: {node: '>=6.14.2'} + resolution: + { integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw== } + engines: { node: '>=6.14.2' } bufio@1.2.3: - resolution: {integrity: sha512-5Tt66bRzYUSlVZatc0E92uDenreJ+DpTBmSAUwL4VSxJn3e6cUyYwx+PoqML0GRZatgA/VX8ybhxItF8InZgqA==} - engines: {node: '>=8.0.0'} + resolution: + { integrity: sha512-5Tt66bRzYUSlVZatc0E92uDenreJ+DpTBmSAUwL4VSxJn3e6cUyYwx+PoqML0GRZatgA/VX8ybhxItF8InZgqA== } + engines: { node: '>=8.0.0' } builtin-modules@3.3.0: - resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== } + engines: { node: '>=6' } builtins@5.1.0: - resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} + resolution: + { integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg== } bunyan-blackhole@1.1.1: - resolution: {integrity: sha512-UwzNPhbbSqbzeJhCbygqjlAY7p0ZUdv1ADXPQvDh3CA7VW3C/rCc1gaQO/8j9QL4vsKQCQZQSQIEwX+lxioPAQ==} + resolution: + { integrity: sha512-UwzNPhbbSqbzeJhCbygqjlAY7p0ZUdv1ADXPQvDh3CA7VW3C/rCc1gaQO/8j9QL4vsKQCQZQSQIEwX+lxioPAQ== } peerDependencies: bunyan: ~1.x.x bunyan@1.8.15: - resolution: {integrity: sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==} - engines: {'0': node >=0.10.0} + resolution: + { integrity: sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig== } + engines: { '0': node >=0.10.0 } hasBin: true bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== } + engines: { node: '>= 0.8' } cache-base@1.0.1: - resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== } + engines: { node: '>=0.10.0' } call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== } + engines: { node: '>= 0.4' } call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== } + engines: { node: '>= 0.4' } call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== } + engines: { node: '>= 0.4' } callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== } + engines: { node: '>=6' } camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== } + engines: { node: '>=6' } camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== } + engines: { node: '>=10' } caniuse-lite@1.0.30001717: - resolution: {integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==} + resolution: + { integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw== } capability@0.2.5: - resolution: {integrity: sha512-rsJZYVCgXd08sPqwmaIqjAd5SUTfonV0z/gDJ8D6cN8wQphky1kkAYEqQ+hmDxTw7UihvBfjUVUSY+DBEe44jg==} + resolution: + { integrity: sha512-rsJZYVCgXd08sPqwmaIqjAd5SUTfonV0z/gDJ8D6cN8wQphky1kkAYEqQ+hmDxTw7UihvBfjUVUSY+DBEe44jg== } catering@2.1.1: - resolution: {integrity: sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w== } + engines: { node: '>=6' } chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== } + engines: { node: '>=4' } chain@0.4.2: - resolution: {integrity: sha512-GtM+TlN398yBhtSp1D2dBLQomKM3Umbji3h2/NdCqAWSMKhWbjlz33j0e55rStsEZD+8OLRHuz7kWd0U3xKMDg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-GtM+TlN398yBhtSp1D2dBLQomKM3Umbji3h2/NdCqAWSMKhWbjlz33j0e55rStsEZD+8OLRHuz7kWd0U3xKMDg== } + engines: { node: '>=18' } chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== } + engines: { node: '>=4' } chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== } + engines: { node: '>=10' } chalk@5.6.0: - resolution: {integrity: sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + 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} + 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'} + resolution: + { integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== } + engines: { node: '>=10' } chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + resolution: + { integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== } check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + resolution: + { integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== } check-more-types@2.24.0: - resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA== } + engines: { node: '>= 0.8.0' } chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + resolution: + { integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== } + engines: { node: '>= 8.10.0' } chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} + resolution: + { integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== } + engines: { node: '>= 14.16.0' } chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + resolution: + { integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== } ci-info@2.0.0: - resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + resolution: + { integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== } ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== } + engines: { node: '>=8' } cipher-base@1.0.6: - resolution: {integrity: sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==} - engines: {node: '>= 0.10'} + resolution: + { integrity: sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw== } + engines: { node: '>= 0.10' } cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + resolution: + { integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== } class-utils@0.3.6: - resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== } + engines: { node: '>=0.10.0' } classic-level@1.4.1: - resolution: {integrity: sha512-qGx/KJl3bvtOHrGau2WklEZuXhS3zme+jf+fsu6Ej7W7IP/C49v7KNlWIsT1jZu0YnfzSIYDGcEWpCa1wKGWXQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-qGx/KJl3bvtOHrGau2WklEZuXhS3zme+jf+fsu6Ej7W7IP/C49v7KNlWIsT1jZu0YnfzSIYDGcEWpCa1wKGWXQ== } + engines: { node: '>=12' } clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== } + engines: { node: '>=6' } cli-boxes@2.2.1: - resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== } + engines: { node: '>=6' } cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== } + engines: { node: '>=8' } cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw== } + engines: { node: '>=18' } cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== } + engines: { node: '>=6' } cli-truncate@4.0.0: - resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} - engines: {node: '>=18'} + 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'} + resolution: + { integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== } + engines: { node: '>= 10' } cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + resolution: + { integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== } cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== } + engines: { node: '>=12' } clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} + resolution: + { integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== } + engines: { node: '>=0.8' } clone@2.1.2: - resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} - engines: {node: '>=0.8'} + resolution: + { integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== } + engines: { node: '>=0.8' } co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + 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==} + resolution: + { integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== } collection-visit@1.0.0: - resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw== } + engines: { node: '>=0.10.0' } color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + resolution: + { integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== } color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + resolution: + { integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== } + engines: { node: '>=7.0.0' } color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + resolution: + { integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== } color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + resolution: + { integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== } color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + resolution: + { integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== } color@3.2.1: - resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + resolution: + { integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== } colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + resolution: + { integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== } colorspace@1.1.4: - resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + resolution: + { integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== } combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== } + engines: { node: '>= 0.8' } command-exists@1.2.9: - resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} + resolution: + { integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== } commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== } + engines: { node: '>=18' } commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw== } + engines: { node: '>=18' } commander@14.0.0: - resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} - engines: {node: '>=20'} + resolution: + { integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA== } + engines: { node: '>=20' } commander@14.0.1: - resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} - engines: {node: '>=20'} + resolution: + { integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A== } + engines: { node: '>=20' } commander@14.0.2: - resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} - engines: {node: '>=20'} + resolution: + { integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ== } + engines: { node: '>=20' } commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + resolution: + { integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== } commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} + resolution: + { integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== } + engines: { node: '>= 12' } commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} + resolution: + { integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== } + engines: { node: ^12.20.0 || >=14 } complex.js@2.4.2: - resolution: {integrity: sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==} + resolution: + { integrity: sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g== } component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + resolution: + { integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== } concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: + { integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== } content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== } + engines: { node: '>= 0.6' } content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== } + engines: { node: '>= 0.6' } convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + resolution: + { integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== } cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + resolution: + { integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== } cookie@0.4.2: - resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== } + engines: { node: '>= 0.6' } cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== } + engines: { node: '>= 0.6' } cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== } + engines: { node: '>= 0.6' } copy-descriptor@0.1.1: - resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw== } + engines: { node: '>=0.10.0' } copyfiles@2.4.1: - resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==} + resolution: + { integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg== } hasBin: true core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + resolution: + { integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== } create-hash@1.1.3: - resolution: {integrity: sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==} + resolution: + { integrity: sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA== } create-hash@1.2.0: - resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + resolution: + { integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== } create-hmac@1.1.7: - resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + resolution: + { integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== } create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } hasBin: true create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + resolution: + { integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== } cross-fetch@3.0.6: - resolution: {integrity: sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ==} + resolution: + { integrity: sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== } cross-fetch@3.2.0: - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + resolution: + { integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== } cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== } + engines: { node: '>= 8' } crypto-hash@1.3.0: - resolution: {integrity: sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg== } + engines: { node: '>=8' } crypto-js@4.2.0: - resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + resolution: + { integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== } csv-generate@4.4.2: - resolution: {integrity: sha512-W6nVsf+rz0J3yo9FOjeer7tmzBJKaTTxf7K0uw6GZgRocZYPVpuSWWa5/aoWWrjQZj4/oNIKTYapOM7hiNjVMA==} + resolution: + { integrity: sha512-W6nVsf+rz0J3yo9FOjeer7tmzBJKaTTxf7K0uw6GZgRocZYPVpuSWWa5/aoWWrjQZj4/oNIKTYapOM7hiNjVMA== } csv-parse@5.6.0: - resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} + resolution: + { integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q== } csv-stringify@6.5.2: - resolution: {integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==} + resolution: + { integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA== } csv@6.3.11: - resolution: {integrity: sha512-a8bhT76Q546jOElHcTrkzWY7Py925mfLO/jqquseH61ThOebYwOjLbWHBqdRB4K1VpU36sTyIei6Jwj7QdEZ7g==} - engines: {node: '>= 0.1.90'} + resolution: + { integrity: sha512-a8bhT76Q546jOElHcTrkzWY7Py925mfLO/jqquseH61ThOebYwOjLbWHBqdRB4K1VpU36sTyIei6Jwj7QdEZ7g== } + engines: { node: '>= 0.1.90' } data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== } + engines: { node: '>= 0.4' } data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== } + engines: { node: '>= 0.4' } data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== } + engines: { node: '>= 0.4' } dateformat@4.6.3: - resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + resolution: + { integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== } dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + resolution: + { integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== } debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + resolution: + { integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== } peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -3762,7 +4569,8 @@ packages: optional: true debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + resolution: + { integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== } peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -3770,8 +4578,9 @@ packages: optional: true debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} + resolution: + { integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== } + engines: { node: '>=6.0' } peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -3779,8 +4588,9 @@ packages: optional: true debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} - engines: {node: '>=6.0'} + resolution: + { integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== } + engines: { node: '>=6.0' } peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -3788,8 +4598,9 @@ packages: optional: true debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} - engines: {node: '>=6.0'} + resolution: + { integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== } + engines: { node: '>=6.0' } peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -3797,25 +4608,31 @@ packages: optional: true decamelize@4.0.0: - resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== } + engines: { node: '>=10' } decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + resolution: + { integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== } decimal.js@10.5.0: - resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + resolution: + { integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw== } decode-uri-component@0.2.2: - resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} - engines: {node: '>=0.10'} + resolution: + { integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== } + engines: { node: '>=0.10' } decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== } + engines: { node: '>=10' } dedent@1.6.0: - resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} + resolution: + { integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA== } peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: @@ -3823,265 +4640,334 @@ packages: optional: true deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== } + engines: { node: '>=6' } deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} + resolution: + { integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== } + engines: { node: '>=4.0.0' } deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + resolution: + { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== } deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== } + engines: { node: '>=0.10.0' } defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + resolution: + { integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== } define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== } + engines: { node: '>= 0.4' } define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== } + engines: { node: '>= 0.4' } define-property@0.2.5: - resolution: {integrity: sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA== } + engines: { node: '>=0.10.0' } define-property@1.0.0: - resolution: {integrity: sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA== } + engines: { node: '>=0.10.0' } define-property@2.0.2: - resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== } + engines: { node: '>=0.10.0' } delay@5.0.0: - resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== } + engines: { node: '>=10' } delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} + resolution: + { integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== } + engines: { node: '>=0.4.0' } depd@1.1.2: - resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== } + engines: { node: '>= 0.6' } depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== } + engines: { node: '>= 0.8' } destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + resolution: + { integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== } + engines: { node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16 } detect-libc@2.0.4: - resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== } + engines: { node: '>=8' } detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== } + engines: { node: '>=8' } diff-sequences@24.9.0: - resolution: {integrity: sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== } + engines: { node: '>= 6' } diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} + resolution: + { integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== } + engines: { node: '>=0.3.1' } diff@5.2.0: - resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} - engines: {node: '>=0.3.1'} + resolution: + { integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== } + engines: { node: '>=0.3.1' } dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== } + engines: { node: '>=8' } doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== } + engines: { node: '>=0.10.0' } doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== } + engines: { node: '>=6.0.0' } dot-case@3.0.4: - resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + resolution: + { integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== } dotenv@14.3.2: - resolution: {integrity: sha512-vwEppIphpFdvaMCaHfCEv9IgwcxMljMw2TnAQBB4VWPvzXQLTb82jwmdOKzlEVUL3gNFT4l4TPKO+Bn+sqcrVQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-vwEppIphpFdvaMCaHfCEv9IgwcxMljMw2TnAQBB4VWPvzXQLTb82jwmdOKzlEVUL3gNFT4l4TPKO+Bn+sqcrVQ== } + engines: { node: '>=12' } dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg== } + engines: { node: '>=12' } dtrace-provider@0.8.8: - resolution: {integrity: sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==} - engines: {node: '>=0.10'} + resolution: + { integrity: sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg== } + engines: { node: '>=0.10' } dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== } + engines: { node: '>= 0.4' } duplexer@0.1.2: - resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + resolution: + { integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== } eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + resolution: + { integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== } ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: + { integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== } eip55@2.1.1: - resolution: {integrity: sha512-WcagVAmNu2Ww2cDUfzuWVntYwFxbvZ5MvIyLZpMjTTkjD6sCvkGOiS86jTppzu9/gWsc8isLHAeMBWK02OnZmA==} + resolution: + { integrity: sha512-WcagVAmNu2Ww2cDUfzuWVntYwFxbvZ5MvIyLZpMjTTkjD6sCvkGOiS86jTppzu9/gWsc8isLHAeMBWK02OnZmA== } ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== } + engines: { node: '>=0.10.0' } hasBin: true electron-to-chromium@1.5.151: - resolution: {integrity: sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA==} + resolution: + { integrity: sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA== } elliptic@6.6.1: - resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + resolution: + { integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g== } emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== } + engines: { node: '>=12' } emoji-regex@10.4.0: - resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + resolution: + { integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw== } emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + resolution: + { integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== } emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + resolution: + { integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== } enabled@2.0.0: - resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + resolution: + { integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== } encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== } + engines: { node: '>= 0.8' } encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== } + engines: { node: '>= 0.8' } encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + resolution: + { integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== } end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + resolution: + { integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== } enquirer@2.4.1: - resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} - engines: {node: '>=8.6'} + resolution: + { integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== } + engines: { node: '>=8.6' } env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== } + engines: { node: '>=6' } environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== } + engines: { node: '>=18' } error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + resolution: + { integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== } error-polyfill@0.1.3: - resolution: {integrity: sha512-XHJk60ufE+TG/ydwp4lilOog549iiQF2OAPhkk9DdiYWMrltz5yhDz/xnKuenNwP7gy3dsibssO5QpVhkrSzzg==} + resolution: + { integrity: sha512-XHJk60ufE+TG/ydwp4lilOog549iiQF2OAPhkk9DdiYWMrltz5yhDz/xnKuenNwP7gy3dsibssO5QpVhkrSzzg== } es-abstract@1.23.9: - resolution: {integrity: sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA== } + engines: { node: '>= 0.4' } es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== } + engines: { node: '>= 0.4' } es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== } + engines: { node: '>= 0.4' } es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== } + engines: { node: '>= 0.4' } es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== } + engines: { node: '>= 0.4' } es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== } + engines: { node: '>= 0.4' } es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== } + engines: { node: '>= 0.4' } es6-promise@4.2.8: - resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + resolution: + { integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== } es6-promisify@5.0.0: - resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + resolution: + { integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ== } esbuild@0.25.5: - resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ== } + engines: { node: '>=18' } hasBin: true escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== } + engines: { node: '>=6' } escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + resolution: + { integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== } escape-latex@1.2.0: - resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==} + resolution: + { integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw== } escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} + 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'} + resolution: + { integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== } + engines: { node: '>=8' } escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== } + engines: { node: '>=10' } eslint-compat-utils@0.5.1: - resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q== } + engines: { node: '>=12' } peerDependencies: eslint: '>=6.0.0' eslint-config-prettier@9.1.0: - resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + resolution: + { integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw== } hasBin: true peerDependencies: eslint: '>=7.0.0' eslint-config-standard@17.1.0: - resolution: {integrity: sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q== } + engines: { node: '>=12.0.0' } peerDependencies: eslint: ^8.0.1 eslint-plugin-import: ^2.25.2 @@ -4089,12 +4975,14 @@ packages: eslint-plugin-promise: ^6.0.0 eslint-formatter-table@7.32.1: - resolution: {integrity: sha512-JYC49hAJMNjLfbgXVeQHU6ngP0M8ThgXCHLGrncYB+R/RHEhRPnLxHjolTJdb7RdQ8zcCt2F7Mrt6Ou3PwMOHw==} - engines: {node: ^10.12.0 || >=12.0.0} + resolution: + { integrity: sha512-JYC49hAJMNjLfbgXVeQHU6ngP0M8ThgXCHLGrncYB+R/RHEhRPnLxHjolTJdb7RdQ8zcCt2F7Mrt6Ou3PwMOHw== } + engines: { node: ^10.12.0 || >=12.0.0 } eslint-import-context@0.1.9: - resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + resolution: + { integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg== } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } peerDependencies: unrs-resolver: ^1.0.0 peerDependenciesMeta: @@ -4102,11 +4990,13 @@ packages: optional: true eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + resolution: + { integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== } eslint-import-resolver-typescript@4.4.3: - resolution: {integrity: sha512-elVDn1eWKFrWlzxlWl9xMt8LltjKl161Ix50JFC50tHXI5/TRP32SNEqlJ/bo/HV+g7Rou/tlPQU2AcRtIhrOg==} - engines: {node: ^16.17.0 || >=18.6.0} + resolution: + { integrity: sha512-elVDn1eWKFrWlzxlWl9xMt8LltjKl161Ix50JFC50tHXI5/TRP32SNEqlJ/bo/HV+g7Rou/tlPQU2AcRtIhrOg== } + engines: { node: ^16.17.0 || >=18.6.0 } peerDependencies: eslint: '*' eslint-plugin-import: '*' @@ -4118,8 +5008,9 @@ packages: optional: true eslint-module-utils@2.12.0: - resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg== } + engines: { node: '>=4' } peerDependencies: '@typescript-eslint/parser': '*' eslint: '*' @@ -4139,14 +5030,16 @@ packages: optional: true eslint-plugin-es-x@7.8.0: - resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} - engines: {node: ^14.18.0 || >=16.0.0} + resolution: + { integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ== } + engines: { node: ^14.18.0 || >=16.0.0 } peerDependencies: eslint: '>=8' eslint-plugin-import@2.31.0: - resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== } + engines: { node: '>=4' } peerDependencies: '@typescript-eslint/parser': '*' eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 @@ -4155,14 +5048,16 @@ packages: optional: true eslint-plugin-n@16.6.2: - resolution: {integrity: sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==} - engines: {node: '>=16.0.0'} + resolution: + { integrity: sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ== } + engines: { node: '>=16.0.0' } peerDependencies: eslint: '>=7.0.0' eslint-plugin-prettier@5.4.0: - resolution: {integrity: sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==} - engines: {node: ^14.18.0 || >=16.0.0} + resolution: + { integrity: sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA== } + engines: { node: ^14.18.0 || >=16.0.0 } peerDependencies: '@types/eslint': '>=8.0.0' eslint: '>=8.0.0' @@ -4175,232 +5070,295 @@ packages: optional: true eslint-plugin-promise@6.6.0: - resolution: {integrity: sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 eslint-plugin-security@2.1.1: - resolution: {integrity: sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==} + resolution: + { integrity: sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w== } eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } 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} + resolution: + { integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== } + engines: { node: '>=4' } hasBin: true esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} + resolution: + { integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== } + engines: { node: '>=0.10' } esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} + resolution: + { integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== } + engines: { node: '>=4.0' } estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} + resolution: + { integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== } + engines: { node: '>=4.0' } esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== } + engines: { node: '>=0.10.0' } etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== } + engines: { node: '>= 0.6' } ethereum-bloom-filters@1.2.0: - resolution: {integrity: sha512-28hyiE7HVsWubqhpVLVmZXFd4ITeHi+BUu05o9isf0GUpMtzBUi+8/gFrGaGYzvGAJQmJ3JKj77Mk9G98T84rA==} + resolution: + { integrity: sha512-28hyiE7HVsWubqhpVLVmZXFd4ITeHi+BUu05o9isf0GUpMtzBUi+8/gFrGaGYzvGAJQmJ3JKj77Mk9G98T84rA== } ethereum-cryptography@0.1.3: - resolution: {integrity: sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ==} + resolution: + { integrity: sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ== } ethereum-cryptography@1.2.0: - resolution: {integrity: sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw==} + resolution: + { integrity: sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw== } ethereum-cryptography@2.2.1: - resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + resolution: + { integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg== } ethereumjs-util@7.1.5: - resolution: {integrity: sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg== } + engines: { node: '>=10.0.0' } ethers@5.8.0: - resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==} + resolution: + { integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg== } ethjs-unit@0.1.6: - resolution: {integrity: sha512-/Sn9Y0oKl0uqQuvgFk/zQgR7aw1g36qX/jzSQ5lSwlO0GigPymk4eGQfeNTD03w1dPOqfz8V77Cy43jH56pagw==} - engines: {node: '>=6.5.0', npm: '>=3'} + resolution: + { integrity: sha512-/Sn9Y0oKl0uqQuvgFk/zQgR7aw1g36qX/jzSQ5lSwlO0GigPymk4eGQfeNTD03w1dPOqfz8V77Cy43jH56pagw== } + engines: { node: '>=6.5.0', npm: '>=3' } event-stream@3.3.4: - resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + resolution: + { integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g== } event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== } + engines: { node: '>=6' } eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + resolution: + { integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== } eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + resolution: + { integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== } events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} + resolution: + { integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== } + engines: { node: '>=0.8.x' } evp_bytestokey@1.0.3: - resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + resolution: + { integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== } execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== } + engines: { node: '>=10' } exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== } + engines: { node: '>= 0.8.0' } expand-brackets@2.1.4: - resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA== } + engines: { node: '>=0.10.0' } expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== } + engines: { node: '>=6' } expect@24.9.0: - resolution: {integrity: sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q== } + engines: { node: '>= 6' } expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } exponential-backoff@3.1.2: - resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} + resolution: + { integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA== } express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} + resolution: + { integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA== } + engines: { node: '>= 0.10.0' } extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== } + engines: { node: '>=0.10.0' } extend-shallow@3.0.2: - resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q== } + engines: { node: '>=0.10.0' } external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== } + engines: { node: '>=4' } extglob@2.0.4: - resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== } + engines: { node: '>=0.10.0' } extract-files@9.0.0: - resolution: {integrity: sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ==} - engines: {node: ^10.17.0 || ^12.0.0 || >= 13.7.0} + resolution: + { integrity: sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ== } + engines: { node: ^10.17.0 || ^12.0.0 || >= 13.7.0 } eyes@0.1.8: - resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} - engines: {node: '> 0.1.90'} + resolution: + { integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== } + engines: { node: '> 0.1.90' } fast-content-type-parse@1.1.0: - resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} + resolution: + { integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ== } fast-copy@3.0.2: - resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + resolution: + { integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ== } fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + resolution: + { integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== } fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + resolution: + { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== } fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + resolution: + { integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== } fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} + resolution: + { integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== } + engines: { node: '>=8.6.0' } fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + resolution: + { integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== } fast-json-stringify@5.16.1: - resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + resolution: + { integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g== } fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + resolution: + { integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== } fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + resolution: + { integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg== } fast-redact@3.5.0: - resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== } + engines: { node: '>=6' } fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + resolution: + { integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== } fast-stable-stringify@1.0.0: - resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + resolution: + { integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag== } fast-uri@2.4.0: - resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} + resolution: + { integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA== } fast-uri@3.0.6: - resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + resolution: + { integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== } fast-xml-parser@4.4.1: - resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} + resolution: + { integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== } hasBin: true fastestsmallesttextencoderdecoder@1.0.22: - resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} + resolution: + { integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw== } fastify-plugin@3.0.1: - resolution: {integrity: sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==} + resolution: + { integrity: sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA== } fastify-plugin@4.5.1: - resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + resolution: + { integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ== } fastify-type-provider-zod@2.1.0: - resolution: {integrity: sha512-p0plQyrxVR1IxJaOVbKRkduYh74HAS6Pm2szhFrc/vFdfIEu8UWo/cPFZCrcKyeo8ICjWbHH1zkx8lWw/ivpqg==} + resolution: + { integrity: sha512-p0plQyrxVR1IxJaOVbKRkduYh74HAS6Pm2szhFrc/vFdfIEu8UWo/cPFZCrcKyeo8ICjWbHH1zkx8lWw/ivpqg== } peerDependencies: fastify: ^4.0.0 zod: ^3.14.2 fastify@4.29.1: - resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==} + resolution: + { integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ== } fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + resolution: + { integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== } fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + resolution: + { integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== } fdir@6.4.4: - resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + resolution: + { integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg== } peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -4408,66 +5366,83 @@ packages: optional: true fecha@4.2.3: - resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + resolution: + { integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== } figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== } + engines: { node: '>=8' } file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + resolution: + { integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== } + engines: { node: ^10.12.0 || >=12.0.0 } file-stream-rotator@0.6.1: - resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} + resolution: + { integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ== } file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + resolution: + { integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== } filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + resolution: + { integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== } fill-range@4.0.0: - resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ== } + engines: { node: '>=0.10.0' } fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== } + engines: { node: '>=8' } finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== } + engines: { node: '>= 0.8' } find-my-way@8.2.2: - resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA== } + engines: { node: '>=14' } find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== } + engines: { node: '>=8' } find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== } + engines: { node: '>=10' } flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + resolution: + { integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== } + engines: { node: ^10.12.0 || >=12.0.0 } flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + resolution: + { integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== } hasBin: true flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + resolution: + { integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== } fn.name@1.1.0: - resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + resolution: + { integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== } follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} - engines: {node: '>=4.0'} + resolution: + { integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== } + engines: { node: '>=4.0' } peerDependencies: debug: '*' peerDependenciesMeta: @@ -4475,208 +5450,261 @@ packages: optional: true for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== } + engines: { node: '>= 0.4' } for-in@1.0.2: - resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== } + engines: { node: '>=0.10.0' } foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== } + engines: { node: '>=14' } form-data@3.0.3: - resolution: {integrity: sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w== } + engines: { node: '>= 6' } form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== } + engines: { node: '>= 6' } form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== } + engines: { node: '>= 6' } forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== } + engines: { node: '>= 0.6' } fp-ts@1.19.3: - resolution: {integrity: sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg==} + resolution: + { integrity: sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg== } fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + resolution: + { integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== } fragment-cache@0.2.1: - resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA== } + engines: { node: '>=0.10.0' } fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== } + engines: { node: '>= 0.6' } from@0.1.7: - resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + resolution: + { integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g== } fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + resolution: + { integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== } fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== } + engines: { node: '>=12' } fs-extra@7.0.1: - resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} - engines: {node: '>=6 <7 || >=8'} + resolution: + { integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== } + engines: { node: '>=6 <7 || >=8' } fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + resolution: + { integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== } fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + resolution: + { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } os: [darwin] function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + resolution: + { integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== } function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== } + engines: { node: '>= 0.4' } functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + resolution: + { integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== } gaussian@1.3.0: - resolution: {integrity: sha512-rYQ0ESfB+z0t7G95nHH80Zh7Pgg9A0FUYoZqV0yPec5WJZWKIHV2MPYpiJNy8oZAeVqyKwC10WXKSCnUQ5iDVg==} - engines: {node: '>= 0.6.0'} + resolution: + { integrity: sha512-rYQ0ESfB+z0t7G95nHH80Zh7Pgg9A0FUYoZqV0yPec5WJZWKIHV2MPYpiJNy8oZAeVqyKwC10WXKSCnUQ5iDVg== } + engines: { node: '>= 0.6.0' } gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== } + engines: { node: '>=6.9.0' } get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} + resolution: + { integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== } + engines: { node: 6.* || 8.* || >= 10.* } get-east-asian-width@1.3.0: - resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ== } + engines: { node: '>=18' } get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + resolution: + { integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== } get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== } + engines: { node: '>= 0.4' } get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} + resolution: + { integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== } + engines: { node: '>=8.0.0' } get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== } + engines: { node: '>= 0.4' } get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== } + engines: { node: '>=10' } get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== } + engines: { node: '>= 0.4' } get-tsconfig@4.10.0: - resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==} + resolution: + { integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A== } get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + resolution: + { integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ== } get-value@2.0.6: - resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA== } + engines: { node: '>=0.10.0' } github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + resolution: + { integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== } glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== } + engines: { node: '>= 6' } glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} + resolution: + { integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== } + engines: { node: '>=10.13.0' } glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + resolution: + { integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== } 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@6.0.4: - resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} + resolution: + { integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A== } 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 glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + resolution: + { integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== } 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 glob@8.1.0: - resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== } + engines: { node: '>=12' } 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 globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== } + engines: { node: '>=4' } globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== } + engines: { node: '>=8' } globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== } + engines: { node: '>= 0.4' } globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== } + engines: { node: '>=10' } gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== } + engines: { node: '>= 0.4' } graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + resolution: + { integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== } graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + resolution: + { integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== } graphql-request@3.7.0: - resolution: {integrity: sha512-dw5PxHCgBneN2DDNqpWu8QkbbJ07oOziy8z+bK/TAXufsOLaETuVO4GkXrbs0WjhdKhBMN3BkpN/RIvUHkmNUQ==} + resolution: + { integrity: sha512-dw5PxHCgBneN2DDNqpWu8QkbbJ07oOziy8z+bK/TAXufsOLaETuVO4GkXrbs0WjhdKhBMN3BkpN/RIvUHkmNUQ== } peerDependencies: graphql: 14 - 16 graphql-request@5.0.0: - resolution: {integrity: sha512-SpVEnIo2J5k2+Zf76cUkdvIRaq5FMZvGQYnA4lUWYbc99m+fHh4CZYRRO/Ff4tCLQ613fzCm3SiDT64ubW5Gyw==} + resolution: + { integrity: sha512-SpVEnIo2J5k2+Zf76cUkdvIRaq5FMZvGQYnA4lUWYbc99m+fHh4CZYRRO/Ff4tCLQ613fzCm3SiDT64ubW5Gyw== } peerDependencies: graphql: 14 - 16 graphql@15.10.1: - resolution: {integrity: sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==} - engines: {node: '>= 10.x'} + resolution: + { integrity: sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg== } + engines: { node: '>= 10.x' } graphql@16.11.0: - resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + resolution: + { integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw== } + engines: { node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0 } hardhat-watcher@2.5.0: - resolution: {integrity: sha512-Su2qcSMIo2YO2PrmJ0/tdkf+6pSt8zf9+4URR5edMVti6+ShI8T3xhPrwugdyTOFuyj8lKHrcTZNKUFYowYiyA==} + resolution: + { integrity: sha512-Su2qcSMIo2YO2PrmJ0/tdkf+6pSt8zf9+4URR5edMVti6+ShI8T3xhPrwugdyTOFuyj8lKHrcTZNKUFYowYiyA== } peerDependencies: hardhat: ^2.0.0 hardhat@2.24.0: - resolution: {integrity: sha512-wDkD5GPmttYv21MR7tGDkyQ22tO2V86OEV8pA7NcXWYUpibe8XZ2EanXCeRHO61vwEx0f7/M+NqrhJwasaNMJg==} + resolution: + { integrity: sha512-wDkD5GPmttYv21MR7tGDkyQ22tO2V86OEV8pA7NcXWYUpibe8XZ2EanXCeRHO61vwEx0f7/M+NqrhJwasaNMJg== } hasBin: true peerDependencies: ts-node: '*' @@ -4688,453 +5716,570 @@ packages: optional: true has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== } + engines: { node: '>= 0.4' } has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== } + engines: { node: '>=4' } has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== } + engines: { node: '>=8' } has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + resolution: + { integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== } has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== } + engines: { node: '>= 0.4' } has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== } + engines: { node: '>= 0.4' } has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== } + engines: { node: '>= 0.4' } has-value@0.3.1: - resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q== } + engines: { node: '>=0.10.0' } has-value@1.0.0: - resolution: {integrity: sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw== } + engines: { node: '>=0.10.0' } has-values@0.1.4: - resolution: {integrity: sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ== } + engines: { node: '>=0.10.0' } has-values@1.0.0: - resolution: {integrity: sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ== } + engines: { node: '>=0.10.0' } hash-base@2.0.2: - resolution: {integrity: sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==} + resolution: + { integrity: sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw== } hash-base@3.1.0: - resolution: {integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== } + engines: { node: '>=4' } hash.js@1.1.7: - resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + resolution: + { integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== } hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== } + engines: { node: '>= 0.4' } he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + resolution: + { integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== } hasBin: true help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + resolution: + { integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg== } hi-base32@0.5.1: - resolution: {integrity: sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==} + resolution: + { integrity: sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA== } hmac-drbg@1.0.1: - resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + resolution: + { integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== } html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + resolution: + { integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== } http-errors@1.8.1: - resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== } + engines: { node: '>= 0.6' } http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== } + engines: { node: '>= 0.8' } https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== } + engines: { node: '>= 6' } human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} + resolution: + { integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== } + engines: { node: '>=10.17.0' } humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + resolution: + { integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== } husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== } + engines: { node: '>=18' } hasBin: true iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== } + engines: { node: '>=0.10.0' } iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== } + engines: { node: '>=0.10.0' } ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + resolution: + { integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== } ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + resolution: + { integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== } + engines: { node: '>= 4' } immutable@4.3.7: - resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} + resolution: + { integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw== } import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== } + engines: { node: '>=6' } import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== } + engines: { node: '>=8' } hasBin: true imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} + resolution: + { integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== } + engines: { node: '>=0.8.19' } indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== } + engines: { node: '>=8' } inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + 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==} + resolution: + { integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== } ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + resolution: + { integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== } inquirer@8.2.6: - resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg== } + engines: { node: '>=12.0.0' } internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== } + engines: { node: '>= 0.4' } invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + resolution: + { integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== } io-ts@1.10.4: - resolution: {integrity: sha512-b23PteSnYXSONJ6JQXRAlvJhuw8KOtkqa87W4wDtvMrud/DTJd5X+NpOOI+O/zZwVq6v0VLAaJ+1EDViKEuN9g==} + resolution: + { integrity: sha512-b23PteSnYXSONJ6JQXRAlvJhuw8KOtkqa87W4wDtvMrud/DTJd5X+NpOOI+O/zZwVq6v0VLAaJ+1EDViKEuN9g== } ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} + resolution: + { integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== } + engines: { node: '>= 0.10' } is-accessor-descriptor@1.0.1: - resolution: {integrity: sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==} - engines: {node: '>= 0.10'} + resolution: + { integrity: sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA== } + engines: { node: '>= 0.10' } is-arguments@1.2.0: - resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== } + engines: { node: '>= 0.4' } is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== } + engines: { node: '>= 0.4' } is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + resolution: + { integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== } is-arrayish@0.3.2: - resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + resolution: + { integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== } is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== } + engines: { node: '>= 0.4' } is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== } + engines: { node: '>= 0.4' } is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== } + engines: { node: '>=8' } is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== } + engines: { node: '>= 0.4' } is-buffer@1.1.6: - resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + resolution: + { integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== } is-buffer@2.0.5: - resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== } + engines: { node: '>=4' } is-builtin-module@3.2.1: - resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A== } + engines: { node: '>=6' } is-bun-module@2.0.0: - resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + resolution: + { integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ== } is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== } + engines: { node: '>= 0.4' } is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== } + engines: { node: '>= 0.4' } is-data-descriptor@1.0.1: - resolution: {integrity: sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw== } + engines: { node: '>= 0.4' } is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== } + engines: { node: '>= 0.4' } is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== } + engines: { node: '>= 0.4' } is-descriptor@0.1.7: - resolution: {integrity: sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg== } + engines: { node: '>= 0.4' } is-descriptor@1.0.3: - resolution: {integrity: sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw== } + engines: { node: '>= 0.4' } is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== } + engines: { node: '>=0.10.0' } is-extendable@1.0.1: - resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== } + engines: { node: '>=0.10.0' } is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== } + engines: { node: '>=0.10.0' } is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== } + engines: { node: '>= 0.4' } is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== } + engines: { node: '>=8' } is-fullwidth-code-point@4.0.0: - resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== } + engines: { node: '>=12' } is-fullwidth-code-point@5.0.0: - resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA== } + engines: { node: '>=18' } is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== } + engines: { node: '>=6' } is-generator-function@1.1.0: - resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== } + engines: { node: '>= 0.4' } is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== } + engines: { node: '>=0.10.0' } is-hex-prefixed@1.0.0: - resolution: {integrity: sha512-WvtOiug1VFrE9v1Cydwm+FnXd3+w9GaeVUss5W4v/SLy3UW00vP+6iNF2SdnfiBoLy4bTqVdkftNGTUeOFVsbA==} - engines: {node: '>=6.5.0', npm: '>=3'} + resolution: + { integrity: sha512-WvtOiug1VFrE9v1Cydwm+FnXd3+w9GaeVUss5W4v/SLy3UW00vP+6iNF2SdnfiBoLy4bTqVdkftNGTUeOFVsbA== } + engines: { node: '>=6.5.0', npm: '>=3' } is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== } + engines: { node: '>=8' } is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== } + engines: { node: '>= 0.4' } is-nan@1.3.2: - resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== } + engines: { node: '>= 0.4' } is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== } + engines: { node: '>= 0.4' } is-number@3.0.0: - resolution: {integrity: sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg== } + engines: { node: '>=0.10.0' } is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + resolution: + { integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== } + engines: { node: '>=0.12.0' } is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== } + engines: { node: '>=8' } is-plain-obj@2.1.0: - resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== } + engines: { node: '>=8' } is-plain-object@2.0.4: - resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== } + engines: { node: '>=0.10.0' } is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== } + engines: { node: '>= 0.4' } is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== } + engines: { node: '>= 0.4' } is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== } + engines: { node: '>= 0.4' } is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== } + engines: { node: '>=8' } is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== } + engines: { node: '>= 0.4' } is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== } + engines: { node: '>= 0.4' } is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== } + engines: { node: '>= 0.4' } is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== } + engines: { node: '>=10' } is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== } + engines: { node: '>= 0.4' } is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== } + engines: { node: '>= 0.4' } is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== } + engines: { node: '>= 0.4' } is-windows@1.0.2: - resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== } + engines: { node: '>=0.10.0' } isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + resolution: + { integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== } isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + resolution: + { integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== } isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + resolution: + { integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== } isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + resolution: + { integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== } isnumber@1.0.0: - resolution: {integrity: sha512-JLiSz/zsZcGFXPrB4I/AGBvtStkt+8QmksyZBZnVXnnK9XdTEyz0tX8CRYljtwYDuIuZzih6DpHQdi+3Q6zHPw==} + resolution: + { integrity: sha512-JLiSz/zsZcGFXPrB4I/AGBvtStkt+8QmksyZBZnVXnnK9XdTEyz0tX8CRYljtwYDuIuZzih6DpHQdi+3Q6zHPw== } isobject@2.1.0: - resolution: {integrity: sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA== } + engines: { node: '>=0.10.0' } isobject@3.0.1: - resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== } + engines: { node: '>=0.10.0' } isomorphic-ws@4.0.1: - resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + resolution: + { integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== } peerDependencies: ws: '*' isows@1.0.7: - resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + resolution: + { integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg== } peerDependencies: ws: '*' istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== } + engines: { node: '>=8' } istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== } + engines: { node: '>=8' } istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== } + engines: { node: '>=10' } istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== } + engines: { node: '>=10' } istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== } + engines: { node: '>=10' } istanbul-reports@3.1.7: - resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== } + engines: { node: '>=8' } jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + resolution: + { integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== } jake@10.9.2: - resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== } + engines: { node: '>=10' } hasBin: true javascript-natural-sort@0.7.1: - resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + resolution: + { integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw== } jayson@4.2.0: - resolution: {integrity: sha512-VfJ9t1YLwacIubLhONk0KFeosUBwstRWQ0IRT1KDjEjnVnSOVHC3uwugyV7L0c7R9lpVyrUGT2XWiBA1UTtpyg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-VfJ9t1YLwacIubLhONk0KFeosUBwstRWQ0IRT1KDjEjnVnSOVHC3uwugyV7L0c7R9lpVyrUGT2XWiBA1UTtpyg== } + engines: { node: '>=8' } hasBin: true jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } hasBin: true peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -5143,8 +6288,9 @@ packages: optional: true jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } peerDependencies: '@types/node': '*' ts-node: '>=9.0.0' @@ -5155,73 +6301,91 @@ packages: optional: true jest-diff@24.9.0: - resolution: {integrity: sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ== } + engines: { node: '>= 6' } jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-extended@0.11.5: - resolution: {integrity: sha512-3RsdFpLWKScpsLD6hJuyr/tV5iFOrw7v6YjA3tPdda9sJwoHwcMROws5gwiIZfcwhHlJRwFJB2OUvGmF3evV/Q==} + resolution: + { integrity: sha512-3RsdFpLWKScpsLD6hJuyr/tV5iFOrw7v6YjA3tPdda9sJwoHwcMROws5gwiIZfcwhHlJRwFJB2OUvGmF3evV/Q== } jest-get-type@22.4.3: - resolution: {integrity: sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w==} + resolution: + { integrity: sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w== } jest-get-type@24.9.0: - resolution: {integrity: sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q== } + engines: { node: '>= 6' } jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-matcher-utils@22.4.3: - resolution: {integrity: sha512-lsEHVaTnKzdAPR5t4B6OcxXo9Vy4K+kRRbG5gtddY8lBEC+Mlpvm1CJcsMESRjzUhzkz568exMV1hTB76nAKbA==} + resolution: + { integrity: sha512-lsEHVaTnKzdAPR5t4B6OcxXo9Vy4K+kRRbG5gtddY8lBEC+Mlpvm1CJcsMESRjzUhzkz568exMV1hTB76nAKbA== } jest-matcher-utils@24.9.0: - resolution: {integrity: sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA== } + engines: { node: '>= 6' } jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-message-util@24.9.0: - resolution: {integrity: sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw== } + engines: { node: '>= 6' } jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== } + engines: { node: '>=6' } peerDependencies: jest-resolve: '*' peerDependenciesMeta: @@ -5229,52 +6393,64 @@ packages: optional: true jest-regex-util@24.9.0: - resolution: {integrity: sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA== } + engines: { node: '>= 6' } jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } hasBin: true peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -5283,467 +6459,596 @@ packages: optional: true joi@17.13.3: - resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + resolution: + { integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA== } joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== } + engines: { node: '>=10' } js-sha256@0.9.0: - resolution: {integrity: sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==} + resolution: + { integrity: sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA== } js-sha3@0.8.0: - resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} + resolution: + { integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== } js-sha512@0.8.0: - resolution: {integrity: sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ==} + resolution: + { integrity: sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ== } js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + resolution: + { integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== } js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + resolution: + { integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== } hasBin: true js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + resolution: + { integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== } hasBin: true jsbi@3.2.5: - resolution: {integrity: sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ==} + resolution: + { integrity: sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ== } jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== } + engines: { node: '>=6' } hasBin: true json-bigint@1.0.0: - resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + resolution: + { integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== } json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + resolution: + { integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== } json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + resolution: + { integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== } json-schema-ref-resolver@1.0.1: - resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + resolution: + { integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw== } json-schema-resolver@2.0.0: - resolution: {integrity: sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg== } + engines: { node: '>=10' } json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + resolution: + { integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== } json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + resolution: + { integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== } json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + resolution: + { integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== } json-stream-stringify@3.1.6: - resolution: {integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==} - engines: {node: '>=7.10.1'} + resolution: + { integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog== } + engines: { node: '>=7.10.1' } json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + resolution: + { integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== } json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + resolution: + { integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== } hasBin: true json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== } + engines: { node: '>=6' } hasBin: true jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + resolution: + { integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== } jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + resolution: + { integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== } keccak@3.0.4: - resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q== } + engines: { node: '>=10.0.0' } keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + resolution: + { integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== } kind-of@3.2.2: - resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== } + engines: { node: '>=0.10.0' } kind-of@4.0.0: - resolution: {integrity: sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw== } + engines: { node: '>=0.10.0' } kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== } + engines: { node: '>=0.10.0' } kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== } + engines: { node: '>=6' } kuler@2.0.0: - resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + resolution: + { integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== } lazy-ass@1.6.0: - resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} - engines: {node: '> 0.8'} + resolution: + { integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw== } + engines: { node: '> 0.8' } level-supports@4.0.1: - resolution: {integrity: sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA== } + engines: { node: '>=12' } level-transcoder@1.0.1: - resolution: {integrity: sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w== } + engines: { node: '>=12' } level@8.0.1: - resolution: {integrity: sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ== } + engines: { node: '>=12' } leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== } + engines: { node: '>=6' } levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== } + engines: { node: '>= 0.8.0' } light-my-request@5.14.0: - resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} + resolution: + { integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA== } lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== } + engines: { node: '>=14' } lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + resolution: + { integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== } lint-staged@16.1.2: - resolution: {integrity: sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==} - engines: {node: '>=20.17'} + resolution: + { integrity: sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q== } + engines: { node: '>=20.17' } hasBin: true listr2@8.3.3: - resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ== } + engines: { node: '>=18.0.0' } locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== } + engines: { node: '>=8' } locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== } + engines: { node: '>=10' } lodash.clonedeep@4.5.0: - resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + resolution: + { integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== } lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + resolution: + { integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== } lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + resolution: + { integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== } lodash.truncate@4.4.2: - resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + resolution: + { integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== } lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + resolution: + { integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== } log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== } + engines: { node: '>=10' } log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w== } + engines: { node: '>=18' } logform@2.7.0: - resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ== } + engines: { node: '>= 12.0.0' } loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + resolution: + { integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== } hasBin: true loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + resolution: + { integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== } lower-case@2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + resolution: + { integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== } lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + resolution: + { integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== } lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + resolution: + { integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== } lru_map@0.3.3: - resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} + resolution: + { integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ== } make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== } + engines: { node: '>=10' } make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + resolution: + { integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== } makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + resolution: + { integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== } map-cache@0.2.2: - resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg== } + engines: { node: '>=0.10.0' } map-stream@0.1.0: - resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + resolution: + { integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g== } map-visit@1.0.0: - resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w== } + engines: { node: '>=0.10.0' } math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== } + engines: { node: '>= 0.4' } mathjs@10.6.4: - resolution: {integrity: sha512-omQyvRE1jIy+3k2qsqkWASOcd45aZguXZDckr3HtnTYyXk5+2xpVfC3kATgbO2Srjxlqww3TVdhD0oUdZ/hiFA==} - engines: {node: '>= 14'} + resolution: + { integrity: sha512-omQyvRE1jIy+3k2qsqkWASOcd45aZguXZDckr3HtnTYyXk5+2xpVfC3kATgbO2Srjxlqww3TVdhD0oUdZ/hiFA== } + engines: { node: '>= 14' } hasBin: true md5.js@1.3.5: - resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + resolution: + { integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== } media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== } + engines: { node: '>= 0.6' } memorystream@0.3.1: - resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} - engines: {node: '>= 0.10.0'} + resolution: + { integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== } + engines: { node: '>= 0.10.0' } merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + resolution: + { integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== } merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + resolution: + { integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== } merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== } + engines: { node: '>= 8' } merkletreejs@0.3.11: - resolution: {integrity: sha512-LJKTl4iVNTndhL+3Uz/tfkjD0klIWsHlUzgtuNnNrsf7bAlXR30m+xYB7lHr5Z/l6e/yAIsr26Dabx6Buo4VGQ==} - engines: {node: '>= 7.6.0'} + resolution: + { integrity: sha512-LJKTl4iVNTndhL+3Uz/tfkjD0klIWsHlUzgtuNnNrsf7bAlXR30m+xYB7lHr5Z/l6e/yAIsr26Dabx6Buo4VGQ== } + engines: { node: '>= 7.6.0' } methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== } + engines: { node: '>= 0.6' } micro-eth-signer@0.14.0: - resolution: {integrity: sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw==} + resolution: + { integrity: sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw== } micro-packed@0.7.3: - resolution: {integrity: sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==} + resolution: + { integrity: sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg== } micromatch@3.1.10: - resolution: {integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== } + engines: { node: '>=0.10.0' } micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + resolution: + { integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== } + engines: { node: '>=8.6' } mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== } + engines: { node: '>= 0.6' } mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== } + engines: { node: '>= 0.6' } mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== } + engines: { node: '>=4' } hasBin: true mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== } + engines: { node: '>=10.0.0' } hasBin: true mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== } + engines: { node: '>=6' } mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== } + engines: { node: '>=18' } mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== } + engines: { node: '>=10' } minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + resolution: + { integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== } minimalistic-crypto-utils@1.0.1: - resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + resolution: + { integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== } minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + resolution: + { integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== } minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== } + engines: { node: '>=10' } minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + resolution: + { integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== } + engines: { node: '>=16 || 14 >=14.17' } minimist@0.2.4: - resolution: {integrity: sha512-Pkrrm8NjyQ8yVt8Am9M+yUt74zE3iokhzbG1bFVNjLB92vwM71hf40RkEsryg98BujhVOncKm/C1xROxZ030LQ==} + resolution: + { integrity: sha512-Pkrrm8NjyQ8yVt8Am9M+yUt74zE3iokhzbG1bFVNjLB92vwM71hf40RkEsryg98BujhVOncKm/C1xROxZ030LQ== } minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} + resolution: + { integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== } + engines: { node: '>=16 || 14 >=14.17' } mixin-deep@1.3.2: - resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== } + engines: { node: '>=0.10.0' } mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + resolution: + { integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== } mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== } + engines: { node: '>=10' } hasBin: true mnemonist@0.38.5: - resolution: {integrity: sha512-bZTFT5rrPKtPJxj8KSV0WkPyNxl72vQepqqVUAW2ARUpUSF2qXMB6jZj7hW5/k7C1rtpzqbD/IIbJwLXUjCHeg==} + resolution: + { integrity: sha512-bZTFT5rrPKtPJxj8KSV0WkPyNxl72vQepqqVUAW2ARUpUSF2qXMB6jZj7hW5/k7C1rtpzqbD/IIbJwLXUjCHeg== } mocha@10.8.2: - resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} - engines: {node: '>= 14.0.0'} + resolution: + { integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg== } + engines: { node: '>= 14.0.0' } hasBin: true module-error@1.0.2: - resolution: {integrity: sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA== } + engines: { node: '>=10' } moment@2.30.1: - resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + resolution: + { integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== } ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + resolution: + { integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== } ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + resolution: + { integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== } ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + resolution: + { integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== } multistream@4.1.0: - resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} + resolution: + { integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw== } mustache@4.2.0: - resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + resolution: + { integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== } hasBin: true mute-stream@0.0.8: - resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + resolution: + { integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== } mv@2.1.1: - resolution: {integrity: sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==} - engines: {node: '>=0.8.0'} + resolution: + { integrity: sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg== } + engines: { node: '>=0.8.0' } mylas@2.1.13: - resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg== } + engines: { node: '>=12.0.0' } nan@2.22.2: - resolution: {integrity: sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==} + resolution: + { integrity: sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ== } nano-spawn@1.0.2: - resolution: {integrity: sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==} - engines: {node: '>=20.17'} + resolution: + { integrity: sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg== } + engines: { node: '>=20.17' } nanomatch@1.2.13: - resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== } + engines: { node: '>=0.10.0' } napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + resolution: + { integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== } napi-macros@2.2.2: - resolution: {integrity: sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==} + resolution: + { integrity: sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g== } napi-postinstall@0.2.5: - resolution: {integrity: sha512-kmsgUvCRIJohHjbZ3V8avP0I1Pekw329MVAMDzVxsrkjgdnqiwvMX5XwR+hWV66vsAtZ+iM+fVnq8RTQawUmCQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + resolution: + { integrity: sha512-kmsgUvCRIJohHjbZ3V8avP0I1Pekw329MVAMDzVxsrkjgdnqiwvMX5XwR+hWV66vsAtZ+iM+fVnq8RTQawUmCQ== } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } hasBin: true natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + resolution: + { integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== } ncp@2.0.0: - resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==} + resolution: + { integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA== } hasBin: true near-api-js@0.44.2: - resolution: {integrity: sha512-eMnc4V+geggapEUa3nU2p8HSHn/njtloI4P2mceHQWO8vDE1NGpnAw8FuTBrLmXSgIv9m6oocgFc9t3VNf5zwg==} + resolution: + { integrity: sha512-eMnc4V+geggapEUa3nU2p8HSHn/njtloI4P2mceHQWO8vDE1NGpnAw8FuTBrLmXSgIv9m6oocgFc9t3VNf5zwg== } near-hd-key@1.2.1: - resolution: {integrity: sha512-SIrthcL5Wc0sps+2e1xGj3zceEa68TgNZDLuCx0daxmfTP7sFTB3/mtE2pYhlFsCxWoMn+JfID5E1NlzvvbRJg==} + resolution: + { integrity: sha512-SIrthcL5Wc0sps+2e1xGj3zceEa68TgNZDLuCx0daxmfTP7sFTB3/mtE2pYhlFsCxWoMn+JfID5E1NlzvvbRJg== } near-seed-phrase@0.2.1: - resolution: {integrity: sha512-feMuums+kVL3LSuPcP4ld07xHCb2mu6z48SGfP3W+8tl1Qm5xIcjiQzY2IDPBvFgajRDxWSb8GzsRHoInazByw==} + resolution: + { integrity: sha512-feMuums+kVL3LSuPcP4ld07xHCb2mu6z48SGfP3W+8tl1Qm5xIcjiQzY2IDPBvFgajRDxWSb8GzsRHoInazByw== } negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== } + engines: { node: '>= 0.6' } no-case@3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + resolution: + { integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== } no-case@4.0.0: - resolution: {integrity: sha512-WmS3EUGw+vXHlTgiUPi3NzbZNwH6+uGX0QLGgqG+aFSJ5rkX/Ee0nuwHBJfZTfQwwR8lGO819NEIwQ7CGhkdEQ==} + resolution: + { integrity: sha512-WmS3EUGw+vXHlTgiUPi3NzbZNwH6+uGX0QLGgqG+aFSJ5rkX/Ee0nuwHBJfZTfQwwR8lGO819NEIwQ7CGhkdEQ== } deprecated: Use `change-case` node-abi@3.75.0: - resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg== } + engines: { node: '>=10' } node-addon-api@2.0.2: - resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} + resolution: + { integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== } node-addon-api@3.2.1: - resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} + resolution: + { integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== } node-addon-api@5.1.0: - resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + resolution: + { integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== } node-addon-api@6.1.0: - resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + resolution: + { integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== } node-cache@5.1.2: - resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} - engines: {node: '>= 8.0.0'} + resolution: + { integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== } + engines: { node: '>= 8.0.0' } node-fetch@2.6.1: - resolution: {integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==} - engines: {node: 4.x || >=6.0.0} + resolution: + { integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== } + engines: { node: 4.x || >=6.0.0 } node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} + resolution: + { integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== } + engines: { node: 4.x || >=6.0.0 } peerDependencies: encoding: ^0.1.0 peerDependenciesMeta: @@ -5751,128 +7056,161 @@ packages: optional: true node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + resolution: + { integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== } hasBin: true node-hid@2.1.2: - resolution: {integrity: sha512-qhCyQqrPpP93F/6Wc/xUR7L8mAJW0Z6R7HMQV8jCHHksAxNDe/4z4Un/H9CpLOT+5K39OPyt9tIQlavxWES3lg==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-qhCyQqrPpP93F/6Wc/xUR7L8mAJW0Z6R7HMQV8jCHHksAxNDe/4z4Un/H9CpLOT+5K39OPyt9tIQlavxWES3lg== } + engines: { node: '>=10' } hasBin: true node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + resolution: + { integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== } node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + resolution: + { integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== } noms@0.0.0: - resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} + resolution: + { integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow== } normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== } + engines: { node: '>=0.10.0' } npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== } + engines: { node: '>=8' } number-to-bn@1.7.0: - resolution: {integrity: sha512-wsJ9gfSz1/s4ZsJN01lyonwuxA1tml6X1yBDnfpMglypcBRFZZkus26EdPSlqS5GJfYddVZa22p3VNb3z5m5Ig==} - engines: {node: '>=6.5.0', npm: '>=3'} + resolution: + { integrity: sha512-wsJ9gfSz1/s4ZsJN01lyonwuxA1tml6X1yBDnfpMglypcBRFZZkus26EdPSlqS5GJfYddVZa22p3VNb3z5m5Ig== } + engines: { node: '>=6.5.0', npm: '>=3' } o3@1.0.3: - resolution: {integrity: sha512-f+4n+vC6s4ysy7YO7O2gslWZBUu8Qj2i2OUJOvjRxQva7jVjYjB29jrr9NCjmxZQR0gzrOcv1RnqoYOeMs5VRQ==} + resolution: + { integrity: sha512-f+4n+vC6s4ysy7YO7O2gslWZBUu8Qj2i2OUJOvjRxQva7jVjYjB29jrr9NCjmxZQR0gzrOcv1RnqoYOeMs5VRQ== } object-copy@0.1.0: - resolution: {integrity: sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ== } + engines: { node: '>=0.10.0' } object-hash@2.2.0: - resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== } + engines: { node: '>= 6' } object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== } + engines: { node: '>= 0.4' } object-is@1.1.6: - resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== } + engines: { node: '>= 0.4' } object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== } + engines: { node: '>= 0.4' } object-visit@1.0.1: - resolution: {integrity: sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA== } + engines: { node: '>=0.10.0' } object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== } + engines: { node: '>= 0.4' } object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== } + engines: { node: '>= 0.4' } object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== } + engines: { node: '>= 0.4' } object.pick@1.3.0: - resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ== } + engines: { node: '>=0.10.0' } object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== } + engines: { node: '>= 0.4' } obliterator@2.0.5: - resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + resolution: + { integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw== } on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== } + engines: { node: '>=14.0.0' } on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== } + engines: { node: '>= 0.8' } once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + resolution: + { integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== } one-time@1.0.0: - resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + resolution: + { integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== } onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== } + engines: { node: '>=6' } onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== } + engines: { node: '>=18' } openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + resolution: + { integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== } optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== } + engines: { node: '>= 0.8.0' } ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== } + engines: { node: '>=10' } os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== } + engines: { node: '>=0.10.0' } own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== } + engines: { node: '>= 0.4' } ox@0.8.1: - resolution: {integrity: sha512-e+z5epnzV+Zuz91YYujecW8cF01mzmrUtWotJ0oEPym/G82uccs7q0WDHTYL3eiONbTUEvcZrptAKLgTBD3u2A==} + resolution: + { integrity: sha512-e+z5epnzV+Zuz91YYujecW8cF01mzmrUtWotJ0oEPym/G82uccs7q0WDHTYL3eiONbTUEvcZrptAKLgTBD3u2A== } peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -5880,7 +7218,8 @@ packages: optional: true ox@0.9.3: - resolution: {integrity: sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg==} + resolution: + { integrity: sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg== } peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -5888,915 +7227,1160 @@ packages: optional: true p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== } + engines: { node: '>=6' } p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== } + engines: { node: '>=10' } p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== } + engines: { node: '>=8' } p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== } + engines: { node: '>=10' } p-map@4.0.0: - resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== } + engines: { node: '>=10' } p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== } + engines: { node: '>=6' } package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + resolution: + { integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== } pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + resolution: + { integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== } parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== } + engines: { node: '>=6' } parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== } + engines: { node: '>=8' } parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== } + engines: { node: '>= 0.8' } pascalcase@0.1.1: - resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw== } + engines: { node: '>=0.10.0' } path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + 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'} + resolution: + { integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== } + engines: { node: '>=0.10.0' } path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== } + engines: { node: '>=8' } path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + resolution: + { integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== } path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + resolution: + { integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== } + engines: { node: '>=16 || 14 >=14.18' } path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + resolution: + { integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== } path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== } + engines: { node: '>=8' } pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + resolution: + { integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== } pause-stream@0.0.11: - resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + resolution: + { integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A== } pbkdf2@3.1.3: - resolution: {integrity: sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==} - engines: {node: '>=0.12'} + resolution: + { integrity: sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA== } + engines: { node: '>=0.12' } picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + resolution: + { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== } picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} + resolution: + { integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== } + engines: { node: '>=8.6' } picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== } + engines: { node: '>=12' } pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} + resolution: + { integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== } + engines: { node: '>=0.10' } hasBin: true pino-abstract-transport@2.0.0: - resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + resolution: + { integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw== } pino-pretty@11.3.0: - resolution: {integrity: sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==} + resolution: + { integrity: sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA== } hasBin: true pino-std-serializers@7.0.0: - resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + resolution: + { integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== } pino@9.6.0: - resolution: {integrity: sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==} + resolution: + { integrity: sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg== } hasBin: true pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== } + engines: { node: '>= 6' } pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== } + engines: { node: '>=8' } plimit-lit@1.6.1: - resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA== } + engines: { node: '>=12' } pnpm@10.10.0: - resolution: {integrity: sha512-1hXbJG/nDyXc/qbY1z3ueCziPiJF48T2+Igkn7VoFJMYY33Kc8LFyO8qTKDVZX+5VnGIv6tH9WbR7mzph4FcOQ==} - engines: {node: '>=18.12'} + resolution: + { integrity: sha512-1hXbJG/nDyXc/qbY1z3ueCziPiJF48T2+Igkn7VoFJMYY33Kc8LFyO8qTKDVZX+5VnGIv6tH9WbR7mzph4FcOQ== } + engines: { node: '>=18.12' } hasBin: true posix-character-classes@0.1.1: - resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg== } + engines: { node: '>=0.10.0' } possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== } + engines: { node: '>= 0.4' } prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== } + engines: { node: '>=10' } deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== } + engines: { node: '>= 0.8.0' } prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== } + engines: { node: '>=6.0.0' } prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw== } + engines: { node: '>=14' } hasBin: true pretty-format@22.4.3: - resolution: {integrity: sha512-S4oT9/sT6MN7/3COoOy+ZJeA92VmOnveLHgrwBE3Z1W5N9S2A1QGNYiE1z75DAENbJrXXUb+OWXhpJcg05QKQQ==} + resolution: + { integrity: sha512-S4oT9/sT6MN7/3COoOy+ZJeA92VmOnveLHgrwBE3Z1W5N9S2A1QGNYiE1z75DAENbJrXXUb+OWXhpJcg05QKQQ== } pretty-format@24.9.0: - resolution: {integrity: sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== } + engines: { node: '>= 6' } pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + resolution: + { integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + resolution: + { integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== } process-warning@3.0.0: - resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + resolution: + { integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ== } process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + resolution: + { integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q== } process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} + resolution: + { integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== } + engines: { node: '>= 0.6.0' } prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== } + engines: { node: '>= 6' } proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} + resolution: + { integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== } + engines: { node: '>= 0.10' } proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + resolution: + { integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== } ps-tree@1.2.0: - resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} - engines: {node: '>= 0.10'} + resolution: + { integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA== } + engines: { node: '>= 0.10' } hasBin: true pump@3.0.2: - resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + resolution: + { integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw== } punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== } + engines: { node: '>=6' } pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + resolution: + { integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== } qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} + resolution: + { integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== } + engines: { node: '>=0.6' } queue-lit@1.5.2: - resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw== } + engines: { node: '>=12' } queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + resolution: + { integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== } quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + resolution: + { integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== } randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + resolution: + { integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== } range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== } + engines: { node: '>= 0.6' } raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== } + engines: { node: '>= 0.8' } rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + resolution: + { integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== } hasBin: true react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + resolution: + { integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== } peerDependencies: react: ^18.3.1 react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + resolution: + { integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== } react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + resolution: + { integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== } react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== } + engines: { node: '>=0.10.0' } readable-stream@1.0.34: - resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} + resolution: + { integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg== } readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + resolution: + { integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== } readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} + resolution: + { integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== } + engines: { node: '>= 6' } readable-stream@4.7.0: - resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + resolution: + { integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== } + engines: { node: '>=8.10.0' } readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} + resolution: + { integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== } + engines: { node: '>= 14.18.0' } real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} + resolution: + { integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== } + engines: { node: '>= 12.13.0' } reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== } + engines: { node: '>= 0.4' } regex-not@1.0.2: - resolution: {integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== } + engines: { node: '>=0.10.0' } regexp-tree@0.1.27: - resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + resolution: + { integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA== } hasBin: true regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== } + engines: { node: '>= 0.4' } repeat-element@1.1.4: - resolution: {integrity: sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ== } + engines: { node: '>=0.10.0' } repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} + resolution: + { integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== } + engines: { node: '>=0.10' } require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== } + engines: { node: '>=0.10.0' } require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== } + engines: { node: '>=0.10.0' } resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== } + engines: { node: '>=8' } resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== } + engines: { node: '>=4' } resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== } + engines: { node: '>=8' } resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolution: + { integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== } resolve-url@0.2.1: - resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} + resolution: + { integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg== } deprecated: https://github.com/lydell/resolve-url#deprecated resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== } + engines: { node: '>=10' } resolve@1.17.0: - resolution: {integrity: sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==} + resolution: + { integrity: sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== } resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== } + engines: { node: '>= 0.4' } hasBin: true restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== } + engines: { node: '>=8' } restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA== } + engines: { node: '>=18' } ret@0.1.15: - resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} - engines: {node: '>=0.12'} + resolution: + { integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== } + engines: { node: '>=0.12' } ret@0.4.3: - resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ== } + engines: { node: '>=10' } retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} + resolution: + { integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== } + engines: { node: '>= 4' } reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + resolution: + { integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== } + engines: { iojs: '>=1.0.0', node: '>=0.10.0' } rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + resolution: + { integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== } rimraf@2.4.5: - resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==} + resolution: + { integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ== } deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + resolution: + { integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== } deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true ripemd160@2.0.1: - resolution: {integrity: sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==} + resolution: + { integrity: sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w== } ripemd160@2.0.2: - resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==} + resolution: + { integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== } rlp@2.2.7: - resolution: {integrity: sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ==} + resolution: + { integrity: sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ== } hasBin: true rpc-websockets@9.1.1: - resolution: {integrity: sha512-1IXGM/TfPT6nfYMIXkJdzn+L4JEsmb0FL1O2OBjaH03V3yuUDdKFulGLMFG6ErV+8pZ5HVC0limve01RyO+saA==} + resolution: + { integrity: sha512-1IXGM/TfPT6nfYMIXkJdzn+L4JEsmb0FL1O2OBjaH03V3yuUDdKFulGLMFG6ErV+8pZ5HVC0limve01RyO+saA== } run-async@2.4.1: - resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} - engines: {node: '>=0.12.0'} + resolution: + { integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== } + engines: { node: '>=0.12.0' } run-parallel-limit@1.1.0: - resolution: {integrity: sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==} + resolution: + { integrity: sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw== } run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + resolution: + { integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== } rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + resolution: + { integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== } safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} + resolution: + { integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== } + engines: { node: '>=0.4' } safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + resolution: + { integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== } safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + resolution: + { integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== } safe-json-stringify@1.2.0: - resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==} + resolution: + { integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg== } safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== } + engines: { node: '>= 0.4' } safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== } + engines: { node: '>= 0.4' } safe-regex2@3.1.0: - resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} + resolution: + { integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug== } safe-regex@1.1.0: - resolution: {integrity: sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==} + resolution: + { integrity: sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg== } safe-regex@2.1.1: - resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + resolution: + { integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A== } safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== } + engines: { node: '>=10' } safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + resolution: + { integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== } scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + resolution: + { integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== } scrypt-js@3.0.1: - resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} + resolution: + { integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA== } secp256k1@4.0.4: - resolution: {integrity: sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw== } + engines: { node: '>=18.0.0' } secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + resolution: + { integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== } seedrandom@3.0.5: - resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + resolution: + { integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== } semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + resolution: + { integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== } hasBin: true semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + resolution: + { integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== } hasBin: true semver@7.7.1: - resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== } + engines: { node: '>=10' } hasBin: true send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== } + engines: { node: '>= 0.8.0' } serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + resolution: + { integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== } serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== } + engines: { node: '>= 0.8.0' } set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + resolution: + { integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ== } set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== } + engines: { node: '>= 0.4' } set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== } + engines: { node: '>= 0.4' } set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== } + engines: { node: '>= 0.4' } set-value@2.0.1: - resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== } + engines: { node: '>=0.10.0' } setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + resolution: + { integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== } setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + resolution: + { integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== } sha.js@2.4.11: - resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} + resolution: + { integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== } hasBin: true shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== } + engines: { node: '>=8' } shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== } + engines: { node: '>=8' } side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== } + engines: { node: '>= 0.4' } side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== } + engines: { node: '>= 0.4' } side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== } + engines: { node: '>= 0.4' } side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== } + engines: { node: '>= 0.4' } signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + resolution: + { integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== } signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} + resolution: + { integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== } + engines: { node: '>=14' } simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + resolution: + { integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== } simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + resolution: + { integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== } simple-swizzle@0.2.2: - resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + resolution: + { integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== } sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + resolution: + { integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== } slash@2.0.0: - resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== } + engines: { node: '>=6' } slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== } + engines: { node: '>=8' } slice-ansi@4.0.0: - resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== } + engines: { node: '>=10' } slice-ansi@5.0.0: - resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== } + engines: { node: '>=12' } slice-ansi@7.1.0: - resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg== } + engines: { node: '>=18' } snake-case@3.0.4: - resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + resolution: + { integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== } snake-case@4.0.0: - resolution: {integrity: sha512-slvG6efKZ3GYUUZdhPOq/lLIqutwQ4TdPViD1VKqsbf0u76U/aPRswPKjOaAS9T7fAPmRmXuN6C/nM0xsMaFLQ==} + resolution: + { integrity: sha512-slvG6efKZ3GYUUZdhPOq/lLIqutwQ4TdPViD1VKqsbf0u76U/aPRswPKjOaAS9T7fAPmRmXuN6C/nM0xsMaFLQ== } deprecated: Use `change-case` snapdragon-node@2.1.1: - resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== } + engines: { node: '>=0.10.0' } snapdragon-util@3.0.1: - resolution: {integrity: sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== } + engines: { node: '>=0.10.0' } snapdragon@0.8.2: - resolution: {integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== } + engines: { node: '>=0.10.0' } solc@0.8.26: - resolution: {integrity: sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g== } + engines: { node: '>=10.0.0' } hasBin: true sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + resolution: + { integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww== } source-map-resolve@0.5.3: - resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==} + resolution: + { integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== } deprecated: See https://github.com/lydell/source-map-resolve#deprecated source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + resolution: + { integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== } source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + resolution: + { integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== } source-map-url@0.4.1: - resolution: {integrity: sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==} + resolution: + { integrity: sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== } deprecated: See https://github.com/lydell/source-map-url#deprecated source-map@0.5.7: - resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== } + engines: { node: '>=0.10.0' } source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== } + engines: { node: '>=0.10.0' } split-string@3.1.0: - resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== } + engines: { node: '>=0.10.0' } split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} + resolution: + { integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== } + engines: { node: '>= 10.x' } split@0.3.3: - resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + resolution: + { integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA== } sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + resolution: + { integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== } stable-hash-x@0.1.1: - resolution: {integrity: sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ== } + engines: { node: '>=12.0.0' } stable-hash-x@0.2.0: - resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ== } + engines: { node: '>=12.0.0' } stack-trace@0.0.10: - resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + resolution: + { integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== } stack-utils@1.0.5: - resolution: {integrity: sha512-KZiTzuV3CnSnSvgMRrARVCj+Ht7rMbauGDK0LdVFRGyenwdylpajAp4Q0i6SX8rEmbTpMMf6ryq2gb8pPq2WgQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-KZiTzuV3CnSnSvgMRrARVCj+Ht7rMbauGDK0LdVFRGyenwdylpajAp4Q0i6SX8rEmbTpMMf6ryq2gb8pPq2WgQ== } + engines: { node: '>=8' } stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== } + engines: { node: '>=10' } stacktrace-parser@0.1.11: - resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg== } + engines: { node: '>=6' } start-server-and-test@1.15.4: - resolution: {integrity: sha512-ucQtp5+UCr0m4aHlY+aEV2JSYNTiMZKdSKK/bsIr6AlmwAWDYDnV7uGlWWEtWa7T4XvRI5cPYcPcQgeLqpz+Tg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-ucQtp5+UCr0m4aHlY+aEV2JSYNTiMZKdSKK/bsIr6AlmwAWDYDnV7uGlWWEtWa7T4XvRI5cPYcPcQgeLqpz+Tg== } + engines: { node: '>=6' } hasBin: true static-extend@0.1.2: - resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g== } + engines: { node: '>=0.10.0' } stats-lite@2.2.0: - resolution: {integrity: sha512-/Kz55rgUIv2KP2MKphwYT/NCuSfAlbbMRv2ZWw7wyXayu230zdtzhxxuXXcvsc6EmmhS8bSJl3uS1wmMHFumbA==} - engines: {node: '>=2.0.0'} + resolution: + { integrity: sha512-/Kz55rgUIv2KP2MKphwYT/NCuSfAlbbMRv2ZWw7wyXayu230zdtzhxxuXXcvsc6EmmhS8bSJl3uS1wmMHFumbA== } + engines: { node: '>=2.0.0' } statuses@1.5.0: - resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== } + engines: { node: '>= 0.6' } statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== } + engines: { node: '>= 0.8' } stream-blackhole@1.0.3: - resolution: {integrity: sha512-7NWl3dkmCd12mPkEwTbBPGxwvxj7L4O9DTjJudn02Fmk9K+RuPaDF8zeGo3kmjbsffU5E1aGpZ1dTR9AaRg6AQ==} + resolution: + { integrity: sha512-7NWl3dkmCd12mPkEwTbBPGxwvxj7L4O9DTjJudn02Fmk9K+RuPaDF8zeGo3kmjbsffU5E1aGpZ1dTR9AaRg6AQ== } stream-chain@2.2.5: - resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + resolution: + { integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA== } stream-combiner@0.0.4: - resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + resolution: + { integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw== } stream-json@1.9.1: - resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + resolution: + { integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw== } stream-transform@3.3.3: - resolution: {integrity: sha512-dALXrXe+uq4aO5oStdHKlfCM/b3NBdouigvxVPxCdrMRAU6oHh3KNss20VbTPQNQmjAHzZGKGe66vgwegFEIog==} + resolution: + { integrity: sha512-dALXrXe+uq4aO5oStdHKlfCM/b3NBdouigvxVPxCdrMRAU6oHh3KNss20VbTPQNQmjAHzZGKGe66vgwegFEIog== } string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} + resolution: + { integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== } + engines: { node: '>=0.6.19' } string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== } + engines: { node: '>=10' } string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== } + engines: { node: '>=8' } string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== } + engines: { node: '>=12' } string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== } + engines: { node: '>=18' } string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== } + engines: { node: '>= 0.4' } string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== } + engines: { node: '>= 0.4' } string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== } + engines: { node: '>= 0.4' } string_decoder@0.10.31: - resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + resolution: + { integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== } string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + resolution: + { integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== } string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + resolution: + { integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== } strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== } + engines: { node: '>=8' } strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== } + engines: { node: '>=12' } strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== } + engines: { node: '>=4' } strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== } + engines: { node: '>=8' } strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== } + engines: { node: '>=6' } strip-hex-prefix@1.0.0: - resolution: {integrity: sha512-q8d4ue7JGEiVcypji1bALTos+0pWtyGlivAWyPuTkHzuTCJqrK9sWxYQZUq6Nq3cuyv3bm734IhHvHtGGURU6A==} - engines: {node: '>=6.5.0', npm: '>=3'} + resolution: + { integrity: sha512-q8d4ue7JGEiVcypji1bALTos+0pWtyGlivAWyPuTkHzuTCJqrK9sWxYQZUq6Nq3cuyv3bm734IhHvHtGGURU6A== } + engines: { node: '>=6.5.0', npm: '>=3' } strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== } + engines: { node: '>=0.10.0' } strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== } + engines: { node: '>=8' } strnum@1.1.2: - resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + resolution: + { integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== } superstruct@0.15.5: - resolution: {integrity: sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ==} + resolution: + { integrity: sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ== } superstruct@2.0.2: - resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} - engines: {node: '>=14.0.0'} + resolution: + { integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A== } + engines: { node: '>=14.0.0' } supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== } + engines: { node: '>=4' } supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== } + engines: { node: '>=8' } supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== } + engines: { node: '>=10' } supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== } + engines: { node: '>= 0.4' } synckit@0.11.4: - resolution: {integrity: sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ==} - engines: {node: ^14.18.0 || >=16.0.0} + resolution: + { integrity: sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ== } + engines: { node: ^14.18.0 || >=16.0.0 } table@6.9.0: - resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A== } + engines: { node: '>=10.0.0' } tar-fs@2.1.3: - resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==} + resolution: + { integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg== } tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== } + engines: { node: '>=6' } test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== } + engines: { node: '>=8' } text-encoding-utf-8@1.0.2: - resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + resolution: + { integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg== } text-hex@1.0.0: - resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + resolution: + { integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== } text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + resolution: + { integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== } thread-stream@3.1.0: - resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + resolution: + { integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== } through2@2.0.5: - resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + resolution: + { integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== } through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + resolution: + { integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== } tiny-emitter@2.1.0: - resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + resolution: + { integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== } tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + resolution: + { integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== } tiny-warning@1.0.3: - resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + resolution: + { integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== } tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== } + engines: { node: '>=12.0.0' } tmp-promise@3.0.3: - resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + resolution: + { integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ== } tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} + resolution: + { integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== } + engines: { node: '>=0.6.0' } tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} - engines: {node: '>=14.14'} + resolution: + { integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== } + engines: { node: '>=14.14' } tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + resolution: + { integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== } to-buffer@1.2.1: - resolution: {integrity: sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ== } + engines: { node: '>= 0.4' } to-object-path@0.3.0: - resolution: {integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg== } + engines: { node: '>=0.10.0' } to-regex-range@2.1.1: - resolution: {integrity: sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg== } + engines: { node: '>=0.10.0' } to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + resolution: + { integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== } + engines: { node: '>=8.0' } to-regex@3.0.2: - resolution: {integrity: sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== } + engines: { node: '>=0.10.0' } toad-cache@3.7.0: - resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw== } + engines: { node: '>=12' } toformat@2.0.0: - resolution: {integrity: sha512-03SWBVop6nU8bpyZCx7SodpYznbZF5R4ljwNLBcTQzKOD9xuihRo/psX58llS1BMFhhAI08H3luot5GoXJz2pQ==} + resolution: + { integrity: sha512-03SWBVop6nU8bpyZCx7SodpYznbZF5R4ljwNLBcTQzKOD9xuihRo/psX58llS1BMFhhAI08H3luot5GoXJz2pQ== } toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} + resolution: + { integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== } + engines: { node: '>=0.6' } toml@3.0.0: - resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + resolution: + { integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== } tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + resolution: + { integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== } treeify@1.1.0: - resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} - engines: {node: '>=0.6'} + resolution: + { integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A== } + engines: { node: '>=0.6' } triple-beam@1.4.1: - resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} - engines: {node: '>= 14.0.0'} + resolution: + { integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== } + engines: { node: '>= 14.0.0' } ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== } + engines: { node: '>=16' } peerDependencies: typescript: '>=4.2.0' ts-jest@29.3.2: - resolution: {integrity: sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + resolution: + { integrity: sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug== } + engines: { node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0 } hasBin: true peerDependencies: '@babel/core': '>=7.0.0-beta.0 <8' @@ -6819,7 +8403,8 @@ packages: optional: true ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + resolution: + { integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== } hasBin: true peerDependencies: '@swc/core': '>=1.2.50' @@ -6833,200 +8418,251 @@ packages: optional: true tsc-alias@1.8.16: - resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} - engines: {node: '>=16.20.2'} + resolution: + { integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g== } + engines: { node: '>=16.20.2' } hasBin: true tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + resolution: + { integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== } tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg== } + engines: { node: '>=6' } tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + resolution: + { integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== } tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + resolution: + { integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== } tsort@0.0.1: - resolution: {integrity: sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw==} + resolution: + { integrity: sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw== } tsx@4.20.3: - resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ== } + engines: { node: '>=18.0.0' } hasBin: true tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + resolution: + { integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== } tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + resolution: + { integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== } type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== } + engines: { node: '>= 0.8.0' } type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== } + engines: { node: '>=4' } type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} + resolution: + { integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== } + engines: { node: '>=4' } type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== } + engines: { node: '>=10' } type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== } + engines: { node: '>=10' } type-fest@0.7.1: - resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg== } + engines: { node: '>=8' } type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== } + engines: { node: '>=16' } type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} + resolution: + { integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== } + engines: { node: '>= 0.6' } typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== } + engines: { node: '>= 0.4' } typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== } + engines: { node: '>= 0.4' } typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== } + engines: { node: '>= 0.4' } typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== } + engines: { node: '>= 0.4' } typed-function@2.1.0: - resolution: {integrity: sha512-bctQIOqx2iVbWGDGPWwIm18QScpu2XRmkC19D8rQGFsjKSgteq/o1hTZvIG/wuDq8fanpBDrLkLq+aEN/6y5XQ==} - engines: {node: '>= 10'} + resolution: + { integrity: sha512-bctQIOqx2iVbWGDGPWwIm18QScpu2XRmkC19D8rQGFsjKSgteq/o1hTZvIG/wuDq8fanpBDrLkLq+aEN/6y5XQ== } + engines: { node: '>= 10' } typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} + resolution: + { integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== } + engines: { node: '>=14.17' } hasBin: true u3@0.1.1: - resolution: {integrity: sha512-+J5D5ir763y+Am/QY6hXNRlwljIeRMZMGs0cT6qqZVVzzT3X3nFPXVyPOFRMOR4kupB0T8JnCdpWdp6Q/iXn3w==} + resolution: + { integrity: sha512-+J5D5ir763y+Am/QY6hXNRlwljIeRMZMGs0cT6qqZVVzzT3X3nFPXVyPOFRMOR4kupB0T8JnCdpWdp6Q/iXn3w== } unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== } + engines: { node: '>= 0.4' } undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + resolution: + { integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== } undici@5.29.0: - resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} - engines: {node: '>=14.0'} + resolution: + { integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg== } + engines: { node: '>=14.0' } union-value@1.0.1: - resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== } + engines: { node: '>=0.10.0' } universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} + resolution: + { integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== } + engines: { node: '>= 4.0.0' } universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} + resolution: + { integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== } + engines: { node: '>= 10.0.0' } unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== } + engines: { node: '>= 0.8' } unrs-resolver@1.9.2: - resolution: {integrity: sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==} + resolution: + { integrity: sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA== } unset-value@1.0.0: - resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ== } + engines: { node: '>=0.10.0' } untildify@4.0.0: - resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== } + engines: { node: '>=8' } update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + resolution: + { integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== } hasBin: true peerDependencies: browserslist: '>= 4.21.0' uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + resolution: + { integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== } urix@0.1.0: - resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==} + resolution: + { integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg== } deprecated: Please see https://github.com/lydell/urix#deprecated usb@2.9.0: - resolution: {integrity: sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw==} - engines: {node: '>=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0'} + resolution: + { integrity: sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw== } + engines: { node: '>=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0' } use@3.1.1: - resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== } + engines: { node: '>=0.10.0' } utf-8-validate@5.0.10: - resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} - engines: {node: '>=6.14.2'} + resolution: + { integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ== } + engines: { node: '>=6.14.2' } utf8@3.0.0: - resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==} + resolution: + { integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ== } util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + resolution: + { integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== } util@0.12.5: - resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + resolution: + { integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== } utility-types@3.11.0: - resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} - engines: {node: '>= 4'} + resolution: + { integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw== } + engines: { node: '>= 4' } utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} + resolution: + { integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== } + engines: { node: '>= 0.4.0' } uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + resolution: + { integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== } deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + resolution: + { integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== } deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + resolution: + { integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== } v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} + resolution: + { integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== } + engines: { node: '>=10.12.0' } vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} + resolution: + { integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== } + engines: { node: '>= 0.8' } viem@2.31.3: - resolution: {integrity: sha512-q3JGI5QFB4LEiLfg9f2ZwjUygAn2W0wMLtj++7E/L2i8Y7zKAkR4TEEOhwBn7gyYXpuc7f1vfd26PJbkEKuj5w==} + resolution: + { integrity: sha512-q3JGI5QFB4LEiLfg9f2ZwjUygAn2W0wMLtj++7E/L2i8Y7zKAkR4TEEOhwBn7gyYXpuc7f1vfd26PJbkEKuj5w== } peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -7034,7 +8670,8 @@ packages: optional: true viem@2.37.1: - resolution: {integrity: sha512-IzacdIXYlOvzDJwNKIVa53LP/LaP70qvBGAIoGH6R+n06S/ru/nnQxLNZ6+JImvIcxwNwgAl0jUA6FZEIQQWSw==} + resolution: + { integrity: sha512-IzacdIXYlOvzDJwNKIVa53LP/LaP70qvBGAIoGH6R+n06S/ru/nnQxLNZ6+JImvIcxwNwgAl0jUA6FZEIQQWSw== } peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -7042,101 +8679,126 @@ packages: optional: true vlq@2.0.4: - resolution: {integrity: sha512-aodjPa2wPQFkra1G8CzJBTHXhgk3EVSwxSWXNPr1fgdFLUb8kvLV1iEb6rFgasIsjP82HWI6dsb5Io26DDnasA==} + resolution: + { integrity: sha512-aodjPa2wPQFkra1G8CzJBTHXhgk3EVSwxSWXNPr1fgdFLUb8kvLV1iEb6rFgasIsjP82HWI6dsb5Io26DDnasA== } wait-on@7.0.1: - resolution: {integrity: sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog== } + engines: { node: '>=12.0.0' } hasBin: true walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + resolution: + { integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== } wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + resolution: + { integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== } web3-utils@1.7.3: - resolution: {integrity: sha512-g6nQgvb/bUpVUIxJE+ezVN+rYwYmlFyMvMIRSuqpi1dk6ApDD00YNArrk7sPcZnjvxOJ76813Xs2vIN2rgh4lg==} - engines: {node: '>=8.0.0'} + resolution: + { integrity: sha512-g6nQgvb/bUpVUIxJE+ezVN+rYwYmlFyMvMIRSuqpi1dk6ApDD00YNArrk7sPcZnjvxOJ76813Xs2vIN2rgh4lg== } + engines: { node: '>=8.0.0' } webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + resolution: + { integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== } whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + resolution: + { integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== } which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== } + engines: { node: '>= 0.4' } which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== } + engines: { node: '>= 0.4' } which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== } + engines: { node: '>= 0.4' } which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} + resolution: + { integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== } + engines: { node: '>= 0.4' } which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== } + engines: { node: '>= 8' } hasBin: true widest-line@3.1.0: - resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== } + engines: { node: '>=8' } winston-daily-rotate-file@4.7.1: - resolution: {integrity: sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA== } + engines: { node: '>=8' } peerDependencies: winston: ^3 winston-transport@4.9.0: - resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A== } + engines: { node: '>= 12.0.0' } winston@3.17.0: - resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw== } + engines: { node: '>= 12.0.0' } word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== } + engines: { node: '>=0.10.0' } workerpool@6.5.1: - resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + resolution: + { integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== } wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== } + engines: { node: '>=8' } wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== } + engines: { node: '>=10' } wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== } + engines: { node: '>=12' } wrap-ansi@9.0.0: - resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q== } + engines: { node: '>=18' } wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + resolution: + { integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== } write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + resolution: + { integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== } + engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } ws@7.4.6: - resolution: {integrity: sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==} - engines: {node: '>=8.3.0'} + resolution: + { integrity: sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== } + engines: { node: '>=8.3.0' } peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -7147,8 +8809,9 @@ packages: optional: true ws@7.5.10: - resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} - engines: {node: '>=8.3.0'} + resolution: + { integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== } + engines: { node: '>=8.3.0' } peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -7159,8 +8822,9 @@ packages: optional: true ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== } + engines: { node: '>=10.0.0' } peerDependencies: bufferutil: ^4.0.1 utf-8-validate: '>=5.0.2' @@ -7171,8 +8835,9 @@ packages: optional: true ws@8.18.2: - resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ== } + engines: { node: '>=10.0.0' } peerDependencies: bufferutil: ^4.0.1 utf-8-validate: '>=5.0.2' @@ -7183,8 +8848,9 @@ packages: optional: true ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} + resolution: + { integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== } + engines: { node: '>=10.0.0' } peerDependencies: bufferutil: ^4.0.1 utf-8-validate: '>=5.0.2' @@ -7195,67 +8861,81 @@ packages: optional: true xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} + resolution: + { integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== } + engines: { node: '>=0.4' } y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== } + engines: { node: '>=10' } yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + resolution: + { integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== } yaml@2.7.1: - resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} - engines: {node: '>= 14'} + resolution: + { integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ== } + engines: { node: '>= 14' } hasBin: true yaml@2.8.0: - resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} - engines: {node: '>= 14.6'} + resolution: + { integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ== } + engines: { node: '>= 14.6' } hasBin: true yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== } + engines: { node: '>=10' } yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== } + engines: { node: '>=12' } yargs-unparser@2.0.0: - resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== } + engines: { node: '>=10' } yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== } + engines: { node: '>=10' } yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== } + engines: { node: '>=12' } yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== } + engines: { node: '>=6' } yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== } + engines: { node: '>=10' } zod-to-json-schema@3.24.5: - resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} + resolution: + { integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== } peerDependencies: zod: ^3.24.1 zod@3.24.4: - resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + resolution: + { integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg== } zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + resolution: + { integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== } snapshots: - '@adraffy/ens-normalize@1.11.0': {} '@ampproject/remapping@2.3.0': diff --git a/scripts/README-testing.md b/scripts/README-testing.md deleted file mode 100644 index 7b132bc4ab..0000000000 --- a/scripts/README-testing.md +++ /dev/null @@ -1,241 +0,0 @@ -# Helius RPC Provider Live Testing Scripts - -This directory contains comprehensive testing scripts for the Helius RPC provider integration. These scripts use real API keys and endpoints for thorough QA testing. - -## Prerequisites - -1. **Gateway Server Running** - ```bash - pnpm start --passphrase=test123 --dev - ``` - -2. **Valid Helius API Key** - - Ensure `conf/rpc/helius.yml` contains a valid API key - - Get your API key from [helius.dev](https://helius.dev) - -3. **Required Dependencies** - ```bash - npm install axios ws js-yaml chalk @solana/web3.js - ``` - -## Testing Scripts - -### 1. Helius Integration Test (`test-helius-live.js`) - -Comprehensive test of all Helius features with real API endpoints. - -```bash -node scripts/test-helius-live.js -``` - -**Tests:** -- ✅ Helius RPC Connection -- ✅ WebSocket Real-time Monitoring -- ✅ Balance Fetching via Helius -- ✅ Token List Loading -- ✅ Priority Fee Estimation -- ✅ Direct Helius RPC Methods -- ✅ Transaction Monitoring Setup -- ✅ Performance vs Standard RPC - -**Expected Output:** -``` -🚀 Helius Live Integration Tests - -[INFO] Running: Helius RPC Connection -[✓] Helius RPC Connection - PASSED (245ms) -[INFO] Running: Helius WebSocket Connection -[✓] WebSocket connected successfully -[✓] Helius WebSocket Connection - PASSED (1.2s) -... - -📊 Test Summary -✓ Passed: 8 -✗ Failed: 0 -Total: 8 -``` - -### 2. Provider Switching Test (`test-provider-switching.js`) - -Tests dynamic switching between URL and Helius providers. - -```bash -node scripts/test-provider-switching.js -``` - -**Tests:** -- ✅ Current Provider Configuration -- ✅ Switch Devnet to Helius -- ✅ Switch Mainnet to URL -- ✅ Invalid Provider Configuration -- ✅ Missing Helius Configuration -- ✅ Provider Performance Comparison -- ✅ Configuration Schema Validation - -**Key Features:** -- Temporarily modifies configs (auto-restores) -- Tests error handling for invalid configs -- Validates schema compliance -- Measures performance differences - -### 3. Performance Benchmark (`test-helius-performance.js`) - -Detailed performance analysis comparing Helius vs standard RPC. - -```bash -# Basic benchmark -node scripts/test-helius-performance.js - -# Custom parameters -node scripts/test-helius-performance.js --iterations=20 --concurrent=5 -``` - -**Benchmarks:** -- 🏁 Balance Fetching Performance -- 🏁 Token List Loading -- 🏁 Concurrent Request Handling -- 🏁 Direct RPC Call Performance -- 🏁 Priority Fee Estimation - -**Sample Output:** -``` -🏁 Helius Performance Benchmark Suite - -[PERF] Helius Balance Fetching Performance Stats: - Average: 189.3ms - Median: 185.0ms - Min: 156.0ms - Max: 245.0ms - 95th: 238.0ms - 99th: 245.0ms - -[✓] Performance improvement: 34.2% - -💡 Recommendations -✓ Helius shows better performance for balance fetching -✓ Helius RPC endpoints are faster than standard -✓ Helius provider shows good reliability -``` - -## Testing Scenarios - -### Scenario 1: Fresh Setup Validation -```bash -# 1. Clean install -rm -rf conf/ -pnpm run setup - -# 2. Verify RPC provider options are presented -# 3. Test both providers -node scripts/test-provider-switching.js -``` - -### Scenario 2: Performance Validation -```bash -# 1. Run comprehensive performance tests -node scripts/test-helius-performance.js --iterations=15 - -# 2. Verify improvements -# - Expect 30-50% faster response times -# - Lower error rates -# - Better concurrent handling -``` - -### Scenario 3: Live Integration Testing -```bash -# 1. Test all Helius features -node scripts/test-helius-live.js - -# 2. Check server logs for: -# - "Connecting to Helius WebSocket (mainnet) endpoint" -# - "Connected to Helius WebSocket for transaction monitoring" -# - "Starting Helius Sender connection warming" - -# 3. Verify no API key exposure in logs -``` - -### Scenario 4: Error Handling Validation -```bash -# 1. Test with invalid API key -echo "apiKey: 'invalid-key'" > conf/rpc/helius.yml -node scripts/test-helius-live.js -# Should gracefully fallback to standard RPC - -# 2. Test missing config -rm conf/rpc/helius.yml -# Server should handle missing config gracefully -``` - -## Expected Performance Improvements - -When using Helius provider, expect: - -| Metric | Improvement | Notes | -|--------|-------------|-------| -| **Balance Fetching** | 30-50% faster | Optimized RPC endpoints | -| **Token List Loading** | 15-25% faster | Cached responses | -| **Concurrent Requests** | 40-60% better | Higher rate limits | -| **Priority Fees** | Real-time data | More accurate estimation | -| **WebSocket Monitoring** | Real-time | vs polling-based | - -## Troubleshooting - -### Common Issues - -1. **WebSocket 401 Errors** - ``` - Error: Unexpected server response: 401 - ``` - - Check API key in `conf/rpc/helius.yml` - - Ensure key has WebSocket permissions - -2. **Config Not Loading** - ``` - Error: Configuration paths must have at least two components - ``` - - Restart Gateway server after config changes - - Verify `conf/rpc/helius.yml` exists - -3. **Performance Not Improving** - - Verify mainnet is using Helius (`rpcProvider: 'helius'`) - - Check API key is valid and has rate limit headroom - - Compare same operations (mainnet vs mainnet, not mainnet vs devnet) - -### Debug Mode - -Enable debug logging: -```bash -# Set log level to debug in server config -# Check logs for detailed Helius service initialization -tail -f logs/logs_gateway_app.log | grep -i helius -``` - -## QA Checklist - -- [ ] All integration tests pass (`test-helius-live.js`) -- [ ] Provider switching works (`test-provider-switching.js`) -- [ ] Performance improvements confirmed (`test-helius-performance.js`) -- [ ] WebSocket connections establish successfully -- [ ] No API keys logged in clear text -- [ ] Error handling works for invalid configs -- [ ] Backward compatibility maintained -- [ ] Server restarts cleanly with new configs -- [ ] Schema validation prevents invalid configurations -- [ ] Both devnet (URL) and mainnet (Helius) work simultaneously - -## Security Notes - -⚠️ **Important**: These scripts use real API keys and make actual network requests. - -- Scripts automatically mask API keys in output -- Test data uses read-only operations -- No private keys or transactions are involved -- API keys are loaded from config files, not hardcoded - -## Support - -For issues with these testing scripts: -1. Check Gateway server is running and accessible -2. Verify Helius API key is valid and has sufficient quota -3. Ensure all dependencies are installed -4. Review server logs for detailed error messages \ No newline at end of file diff --git a/scripts/add-bsc-tokens.ts b/scripts/add-bsc-tokens.ts index f7a55f436f..2582c81c42 100644 --- a/scripts/add-bsc-tokens.ts +++ b/scripts/add-bsc-tokens.ts @@ -43,11 +43,7 @@ async function fetchTokenMetadata(address: string) { const ethereum = await Ethereum.getInstance(NETWORK); const contract = new Contract(address, ERC20_ABI, ethereum.provider); - const [name, symbol, decimals] = await Promise.all([ - contract.name(), - contract.symbol(), - contract.decimals(), - ]); + const [name, symbol, decimals] = await Promise.all([contract.name(), contract.symbol(), contract.decimals()]); return { chainId: BSC_CHAIN_ID, @@ -99,9 +95,7 @@ async function addTokens() { } // Merge and sort by symbol - const allTokens = [...existingTokens, ...newTokens].sort((a, b) => - a.symbol.localeCompare(b.symbol) - ); + const allTokens = [...existingTokens, ...newTokens].sort((a, b) => a.symbol.localeCompare(b.symbol)); // Write updated token list fs.writeFileSync(TOKEN_FILE, JSON.stringify(allTokens, null, 2)); diff --git a/scripts/add-pancakeswap-pools.ts b/scripts/add-pancakeswap-pools.ts index 094946c3af..79edb6ad58 100644 --- a/scripts/add-pancakeswap-pools.ts +++ b/scripts/add-pancakeswap-pools.ts @@ -41,7 +41,7 @@ const TEMPLATE_PATH = path.join(__dirname, '../src/templates/pools/pancakeswap.j async function fetchPancakeswapPoolInfo( poolAddress: string, - type: 'amm' | 'clmm' + type: 'amm' | 'clmm', ): Promise<{ baseTokenAddress: string; quoteTokenAddress: string; diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 0000000000..ea8895e527 --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,90 @@ +/** + * Write the OpenAPI spec to openapi.json without starting a server. + * + * The previous `generate:openapi` curled http://localhost:15888/docs/json, so + * regenerating meant standing up a Gateway first — which kept the committed spec + * from being refreshed in CI, and let it drift from the routes. @fastify/swagger + * can produce the document from the route table alone once the app is ready, so + * this needs nothing running. + * + * Run with: pnpm generate:openapi + */ +import fs from 'fs'; +import path from 'path'; + +// The app builds an HTTPS server unless it is in dev mode, and HTTPS needs the +// cert passphrase. Spec generation touches no sockets, so force dev mode before +// importing the app. +process.env.GATEWAY_TEST_MODE = 'dev'; + +/** + * The port the shipped template configures, rather than the one this machine runs on. + * + * `servers[0].url` is built from `server.port`, so a developer who moved Gateway off the + * default wrote their port into the committed spec — the same way the wallet defaults + * were written in, and with the same effect: the artifact could not match what another + * machine produces. + */ +function templateServerPort(): number { + const template = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'templates', 'server.yml'), 'utf8'); + const match = template.match(/^port:\s*(\d+)/m); + if (!match) { + throw new Error("No `port` in src/templates/server.yml; the spec would carry this machine's port."); + } + return Number(match[1]); +} + +/** + * Replace this machine's configured wallets with the placeholders the templates ship. + * + * The execute routes default `walletAddress` to the chain config's `defaultWallet`, which + * is right at runtime — a caller who omits it means "the wallet I configured" — and wrong + * in a committed artifact. The spec is checked in and vendored by consumers, so whoever + * regenerated it last had their address published, and the file could never match what + * another machine or CI produces. `src/templates/chains/*.yml` already use these + * placeholders; this makes the artifact agree with them. + */ +function withoutLocalWallets(json: string): string { + const placeholders: Array<[string, string]> = []; + + try { + const { getSolanaChainConfig } = require('../src/chains/solana/solana.config'); + placeholders.push([getSolanaChainConfig().defaultWallet, '']); + } catch { + // No Solana config here; nothing of its to redact. + } + try { + const { getEthereumChainConfig } = require('../src/chains/ethereum/ethereum.config'); + placeholders.push([getEthereumChainConfig().defaultWallet, '']); + } catch { + // Likewise for Ethereum. + } + + return placeholders.reduce( + (text, [wallet, placeholder]) => (wallet && !wallet.startsWith('<') ? text.split(wallet).join(placeholder) : text), + json, + ); +} + +async function main() { + const { gatewayApp } = await import('../src/app'); + + await gatewayApp.ready(); + const spec = (gatewayApp as any).swagger(); + + // The document describes Gateway, not this checkout of it. + spec.servers = [{ url: `http://localhost:${templateServerPort()}` }]; + + const outPath = path.resolve(__dirname, '..', 'openapi.json'); + fs.writeFileSync(outPath, `${withoutLocalWallets(JSON.stringify(spec, null, 2))}\n`); + + const paths = Object.keys(spec.paths ?? {}); + console.log(`OpenAPI spec written to ${outPath} (${paths.length} paths)`); + + await gatewayApp.close(); +} + +main().catch((e) => { + console.error('Failed to generate the OpenAPI spec:', e); + process.exit(1); +}); diff --git a/scripts/migrate-pool-templates.ts b/scripts/migrate-pool-templates.ts index 91f1d0bb9b..5372d06bc7 100644 --- a/scripts/migrate-pool-templates.ts +++ b/scripts/migrate-pool-templates.ts @@ -33,7 +33,11 @@ interface PoolInfo { feePct: number; } -async function fetchRaydiumPoolInfo(type: 'amm' | 'clmm', network: string, poolAddress: string): Promise { +async function fetchRaydiumPoolInfo( + type: 'amm' | 'clmm', + network: string, + poolAddress: string, +): Promise { try { const raydium = await Raydium.getInstance(network); @@ -73,7 +77,11 @@ async function fetchMeteoraPoolInfo(network: string, poolAddress: string): Promi } } -async function fetchUniswapPoolInfo(type: 'amm' | 'clmm', network: string, poolAddress: string): Promise { +async function fetchUniswapPoolInfo( + type: 'amm' | 'clmm', + network: string, + poolAddress: string, +): Promise { try { const uniswap = await Uniswap.getInstance(network); const ethereum = await Ethereum.getInstance(network); diff --git a/scripts/test-chainstack-live.ts b/scripts/test-chainstack-live.ts deleted file mode 100644 index fc31902050..0000000000 --- a/scripts/test-chainstack-live.ts +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env -S npx ts-node - -/** - * Live Chainstack Integration Testing Script - * - * Verifies the Chainstack RPC provider integration with a real API key. - * Hits the Gateway's chain status endpoints for every Chainstack-supported - * network and exercises Solana transaction monitoring if a Solana node exists. - * - * Prerequisites: - * - Valid Chainstack API key configured in conf/apiKeys.yml under `chainstack` - * - Gateway chains/solana.yml and chains/ethereum.yml use `rpcProvider: chainstack` - * - At least one running Chainstack node matching each network you want to test - * - Gateway server running: pnpm start --passphrase=test123 --dev - * - * Usage: npx ts-node scripts/test-chainstack-live.ts - */ - -import fs from 'fs'; -import path from 'path'; - -import axios from 'axios'; - -import { ChainstackService } from '../src/rpc/chainstack-service'; - -const GATEWAY_URL = 'http://localhost:15888'; -const CHAINSTACK_API = 'https://api.chainstack.com/v1/nodes'; - -interface TestResult { - name: string; - status: 'passed' | 'failed'; - duration?: number; - error?: string; -} - -const tests: { passed: number; failed: number; results: TestResult[] } = { - passed: 0, - failed: 0, - results: [], -}; - -type LogType = 'info' | 'success' | 'error' | 'warn' | 'test'; - -function log(message: string, type: LogType = 'info'): void { - const timestamp = new Date().toISOString(); - const colors: Record = { - info: '\x1b[34m[INFO]\x1b[0m', - success: '\x1b[32m[✓]\x1b[0m', - error: '\x1b[31m[✗]\x1b[0m', - warn: '\x1b[33m[WARN]\x1b[0m', - test: '\x1b[36m[TEST]\x1b[0m', - }; - console.log(`${timestamp} ${colors[type] || colors.info} ${message}`); -} - -async function testCase(name: string, fn: () => Promise): Promise { - log(`Running: ${name}`, 'test'); - try { - const startTime = Date.now(); - await fn(); - const duration = Date.now() - startTime; - tests.passed++; - tests.results.push({ name, status: 'passed', duration }); - log(`✓ ${name} (${duration}ms)`, 'success'); - } catch (error: any) { - tests.failed++; - tests.results.push({ name, status: 'failed', error: error.message }); - log(`✗ ${name}: ${error.message}`, 'error'); - } -} - -// Per-network expected chainIds. Chain IDs are gateway-side metadata, not -// Chainstack's concern; the supported network list itself comes from -// ChainstackService.getSupportedNetworks() so it stays in sync. -const EXPECTED_CHAIN_IDS: Record = { - 'ethereum:mainnet': 1, - 'ethereum:arbitrum': 42161, - 'ethereum:polygon': 137, - 'ethereum:optimism': 10, - 'ethereum:base': 8453, - 'ethereum:avalanche': 43114, - 'ethereum:bsc': 56, - 'ethereum:celo': 42220, - 'ethereum:sepolia': 11155111, -}; - -async function readChainstackApiKey(): Promise { - const apiKeysPath = path.join(process.cwd(), 'conf', 'apiKeys.yml'); - if (!fs.existsSync(apiKeysPath)) return ''; - const contents = fs.readFileSync(apiKeysPath, 'utf8'); - const match = contents.match(/^chainstack:\s*['"]?([^'"\s]+)['"]?/m); - return match ? match[1] : ''; -} - -async function listChainstackNodes(apiKey: string): Promise { - const response = await axios.get(CHAINSTACK_API, { - headers: { Authorization: `Bearer ${apiKey}` }, - timeout: 10000, - }); - const data = response.data; - return Array.isArray(data) ? data : data.results || []; -} - -async function testChainStatus({ - chain, - network, - expectedChainId, -}: { - chain: 'solana' | 'ethereum'; - network: string; - expectedChainId?: number; -}): Promise { - const response = await axios.get(`${GATEWAY_URL}/chains/${chain}/status?network=${network}`); - if (response.status !== 200) { - throw new Error(`Expected status 200, got ${response.status}`); - } - const data = response.data; - if (expectedChainId !== undefined && data.chainId !== expectedChainId) { - throw new Error(`Expected chainId ${expectedChainId}, got ${data.chainId}`); - } - log(`${chain}/${network}: chainId=${data.chainId ?? 'n/a'}, block=${data.blockNumber ?? 'n/a'}`, 'info'); -} - -async function runTests(): Promise { - log('🚀 Starting Chainstack Integration Tests', 'info'); - - const apiKey = await readChainstackApiKey(); - if (!apiKey) { - log('No chainstack key found in conf/apiKeys.yml — skipping Platform API discovery', 'warn'); - } else { - await testCase('Chainstack Platform API: list_nodes', async () => { - const nodes = await listChainstackNodes(apiKey); - log(`Discovered ${nodes.length} Chainstack node(s)`, 'info'); - nodes.forEach((n: any) => - log(` - ${n.id} ${n.protocol}/${n.network} [${n.status}]`, 'info'), - ); - }); - } - - for (const { chain, network } of ChainstackService.getSupportedNetworks()) { - const expectedChainId = EXPECTED_CHAIN_IDS[`${chain}:${network}`]; - await testCase(`Chain status: ${chain}/${network}`, () => - testChainStatus({ chain, network, expectedChainId }), - ); - } - - log('', 'info'); - log('=== Test Summary ===', 'info'); - log(`Total: ${tests.passed + tests.failed}`, 'info'); - log(`Passed: ${tests.passed}`, tests.passed > 0 ? 'success' : 'info'); - log(`Failed: ${tests.failed}`, tests.failed > 0 ? 'error' : 'info'); - - if (tests.failed > 0) { - log('', 'info'); - log('Failed tests:', 'error'); - tests.results - .filter((r) => r.status === 'failed') - .forEach((r) => log(` - ${r.name}: ${r.error}`, 'error')); - process.exit(1); - } -} - -runTests().catch((err) => { - log(`Fatal: ${err.message}`, 'error'); - process.exit(1); -}); diff --git a/scripts/test-clmm-approvals.js b/scripts/test-clmm-approvals.js deleted file mode 100755 index ddbcbe9b73..0000000000 --- a/scripts/test-clmm-approvals.js +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env node - -/** - * Test script for CLMM dual approval functionality - * Tests both Uniswap and PancakeSwap CLMM approve/allowances endpoints - */ - -const axios = require('axios'); - -const API_URL = 'http://localhost:15888'; - -// Test configuration -const TEST_CONFIG = { - network: 'mainnet', - address: '0xDA50C69342216b538Daf06FfECDa7363E0B96684', - tokens: ['USDC', 'WETH'], -}; - -async function testAllowances(spender, description) { - console.log(`\n📊 Testing allowances for ${description}...`); - - try { - const response = await axios.post(`${API_URL}/chains/ethereum/allowances`, { - chain: 'ethereum', - network: TEST_CONFIG.network, - address: TEST_CONFIG.address, - spender: spender, - tokens: TEST_CONFIG.tokens, - }); - - console.log(`✅ Allowances response:`, JSON.stringify(response.data, null, 2)); - - // Check if spender contains both addresses for CLMM - if (spender.includes('/clmm')) { - const spenderAddresses = response.data.spender.split(','); - if (spenderAddresses.length === 2) { - console.log(`✅ CLMM dual addresses returned:`); - console.log(` - SwapRouter02: ${spenderAddresses[0]}`); - console.log(` - NftManager: ${spenderAddresses[1]}`); - } else { - console.log(`⚠️ Expected 2 addresses for CLMM, got: ${response.data.spender}`); - } - } - - return response.data; - } catch (error) { - console.error(`❌ Error testing allowances:`, error.response?.data || error.message); - return null; - } -} - -async function testApprove(spender, token, amount, description) { - console.log(`\n🔐 Testing approve for ${description}...`); - console.log(` Token: ${token}, Amount: ${amount || 'MAX'}`); - - try { - const response = await axios.post(`${API_URL}/chains/ethereum/approve`, { - chain: 'ethereum', - network: TEST_CONFIG.network, - address: TEST_CONFIG.address, - spender: spender, - token: token, - amount: amount, - }); - - console.log(`✅ Approve response:`, JSON.stringify(response.data, null, 2)); - - // Check if spender contains both addresses for CLMM - if (spender.includes('/clmm') && response.data.data.spender.includes(',')) { - const spenderAddresses = response.data.data.spender.split(','); - console.log(`✅ CLMM dual approval completed for:`); - console.log(` - SwapRouter02: ${spenderAddresses[0]}`); - console.log(` - NftManager: ${spenderAddresses[1]}`); - } - - return response.data; - } catch (error) { - console.error(`❌ Error testing approve:`, error.response?.data || error.message); - return null; - } -} - -async function runTests() { - console.log('🚀 Starting CLMM Dual Approval Tests'); - console.log('====================================='); - console.log(`Network: ${TEST_CONFIG.network}`); - console.log(`Address: ${TEST_CONFIG.address}`); - console.log(`Tokens: ${TEST_CONFIG.tokens.join(', ')}`); - - // Test Uniswap CLMM - console.log('\n\n=== UNISWAP CLMM TESTS ==='); - await testAllowances('uniswap/clmm', 'Uniswap CLMM'); - - // Note: Approve will actually submit a transaction if a valid wallet is configured - // Uncomment below to test approve functionality (requires wallet with funds) - // await testApprove('uniswap/clmm', 'USDC', '100', 'Uniswap CLMM'); - - // Test PancakeSwap CLMM - console.log('\n\n=== PANCAKESWAP CLMM TESTS ==='); - await testAllowances('pancakeswap/clmm', 'PancakeSwap CLMM'); - - // Note: Approve will actually submit a transaction if a valid wallet is configured - // Uncomment below to test approve functionality (requires wallet with funds) - // await testApprove('pancakeswap/clmm', 'USDC', '100', 'PancakeSwap CLMM'); - - // Test regular connectors for comparison - console.log('\n\n=== COMPARISON TESTS ==='); - await testAllowances('uniswap/amm', 'Uniswap AMM (V2)'); - await testAllowances('pancakeswap/amm', 'PancakeSwap AMM (V2)'); - - console.log('\n\n✅ Tests completed!'); -} - -// Run tests -runTests().catch(console.error); \ No newline at end of file diff --git a/scripts/test-helius-live.js b/scripts/test-helius-live.js deleted file mode 100644 index 1a924a3a75..0000000000 --- a/scripts/test-helius-live.js +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env node - -/** - * Live Helius Integration Testing Script - * - * This script tests the Helius RPC provider integration with a real API key. - * It verifies WebSocket connections, Sender endpoints, and RPC functionality. - * - * Prerequisites: - * - Valid Helius API key configured in conf/rpc/helius.yml - * - Gateway server running: pnpm start --passphrase=test123 --dev - * - * Usage: node scripts/test-helius-live.js - */ - -const axios = require('axios'); -const WebSocket = require('ws'); -const { Connection, PublicKey, LAMPORTS_PER_SOL } = require('@solana/web3.js'); - -const GATEWAY_URL = 'http://localhost:15888'; -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; - -// Test configuration -const tests = { - passed: 0, - failed: 0, - results: [] -}; - -// Helper functions (using simple console colors) -function log(message, type = 'info') { - const timestamp = new Date().toISOString(); - const colors = { - info: '\x1b[34m[INFO]\x1b[0m', // blue - success: '\x1b[32m[✓]\x1b[0m', // green - error: '\x1b[31m[✗]\x1b[0m', // red - warn: '\x1b[33m[WARN]\x1b[0m', // yellow - test: '\x1b[36m[TEST]\x1b[0m' // cyan - }; - console.log(`${timestamp} ${colors[type] || colors.info} ${message}`); -} - -async function testCase(name, fn) { - log(`Running: ${name}`, 'test'); - try { - const startTime = Date.now(); - await fn(); - const duration = Date.now() - startTime; - tests.passed++; - tests.results.push({ name, status: 'passed', duration }); - log(`${name} - PASSED (${duration}ms)`, 'success'); - return true; - } catch (error) { - tests.failed++; - tests.results.push({ name, status: 'failed', error: error.message }); - log(`${name} - FAILED: ${error.message}`, 'error'); - return false; - } -} - -// Test 1: Verify Helius RPC Connection -async function testHeliusRPCConnection() { - const response = await axios.post(`${GATEWAY_URL}/chains/solana/status`, { - network: 'mainnet-beta' - }); - - if (!response.data.rpcUrl) { - throw new Error('RPC URL not returned in status'); - } - - // Check if using Helius RPC - parse URL to properly validate hostname - try { - const urlObj = new URL(response.data.rpcUrl); - const allowedHeliusHosts = [ - 'mainnet.helius-rpc.com', - 'devnet.helius-rpc.com', - 'rpc.helius.xyz', - 'mainnet-beta.helius-rpc.com' - ]; - - if (!allowedHeliusHosts.includes(urlObj.hostname)) { - log('Warning: Not using Helius RPC URL', 'warn'); - } - } catch (error) { - log(`Warning: Could not parse RPC URL: ${error.message}`, 'warn'); - } - - return response.data; -} - -// Test 2: Test Helius WebSocket Connection -async function testHeliusWebSocket() { - return new Promise((resolve, reject) => { - // Read Helius config to get API key - const fs = require('fs'); - const yaml = require('js-yaml'); - const configPath = './conf/rpc/helius.yml'; - - if (!fs.existsSync(configPath)) { - reject(new Error('Helius config not found')); - return; - } - - const config = yaml.load(fs.readFileSync(configPath, 'utf8')); - const apiKey = config.apiKey; - - if (!apiKey || apiKey === '') { - reject(new Error('Helius API key not configured')); - return; - } - - const wsUrl = `wss://mainnet.helius-rpc.com/?api-key=${apiKey}`; - const ws = new WebSocket(wsUrl); - - const timeout = setTimeout(() => { - ws.close(); - reject(new Error('WebSocket connection timeout')); - }, 10000); - - ws.on('open', () => { - log('WebSocket connected successfully', 'success'); - - // Test subscription - const subscribeMsg = { - jsonrpc: '2.0', - id: 1, - method: 'programSubscribe', - params: [ - 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', // Token Program - { encoding: 'jsonParsed' } - ] - }; - - ws.send(JSON.stringify(subscribeMsg)); - }); - - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()); - if (msg.result) { - log(`WebSocket subscription ID: ${msg.result}`, 'info'); - clearTimeout(timeout); - ws.close(); - resolve(msg.result); - } - }); - - ws.on('error', (error) => { - clearTimeout(timeout); - reject(error); - }); - }); -} - -// Test 3: Test Balance Fetching with Helius -async function testBalanceFetching() { - const response = await axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLET, - network: 'mainnet-beta', - tokenSymbols: ['SOL'] - }); - - if (!response.data.balances) { - throw new Error('No balances returned'); - } - - const solBalance = response.data.balances.SOL; - log(`SOL Balance: ${solBalance || 0}`, 'info'); - - return response.data; -} - -// Test 4: Test Token List Loading -async function testTokenList() { - const response = await axios.post(`${GATEWAY_URL}/chains/solana/tokens`, { - network: 'mainnet-beta' - }); - - if (!response.data.tokens || !Array.isArray(response.data.tokens)) { - throw new Error('Invalid token list response'); - } - - log(`Loaded ${response.data.tokens.length} tokens`, 'info'); - return response.data; -} - -// Test 5: Test Priority Fee Estimation -async function testPriorityFeeEstimation() { - const response = await axios.get(`${GATEWAY_URL}/chains/solana/estimate-gas?network=mainnet-beta`); - - if (!response.data.priorityFee) { - throw new Error('No priority fee returned'); - } - - log(`Priority Fee: ${response.data.priorityFee} microlamports/CU`, 'info'); - log(`Min Priority Fee: ${response.data.minPriorityFeePerCU} microlamports/CU`, 'info'); - - return response.data; -} - -// Test 6: Test Helius RPC Methods Directly -async function testHeliusRPCMethods() { - const fs = require('fs'); - const yaml = require('js-yaml'); - const configPath = './conf/rpc/helius.yml'; - - const config = yaml.load(fs.readFileSync(configPath, 'utf8')); - const apiKey = config.apiKey; - - const rpcUrl = `https://mainnet.helius-rpc.com/?api-key=${apiKey}`; - const connection = new Connection(rpcUrl, 'confirmed'); - - // Test getLatestBlockhash - const blockhash = await connection.getLatestBlockhash(); - log(`Latest blockhash: ${blockhash.blockhash}`, 'info'); - - // Test getBalance - const pubkey = new PublicKey(TEST_WALLET); - const balance = await connection.getBalance(pubkey); - log(`Direct RPC Balance: ${balance / LAMPORTS_PER_SOL} SOL`, 'info'); - - // Test getAccountInfo - const accountInfo = await connection.getAccountInfo(pubkey); - log(`Account exists: ${accountInfo !== null}`, 'info'); - - return { blockhash, balance, accountInfo: accountInfo !== null }; -} - -// Test 7: Test Transaction Monitoring Setup -async function testTransactionMonitoring() { - // This tests if the Helius service is properly initialized with WebSocket - const response = await axios.post(`${GATEWAY_URL}/chains/solana/status`, { - network: 'mainnet-beta' - }); - - // Check server logs for WebSocket initialization - log('Check server logs for: "Connecting to Helius WebSocket (mainnet) endpoint"', 'info'); - log('Check server logs for: "Connected to Helius WebSocket for transaction monitoring"', 'info'); - - return response.data; -} - -// Test 8: Performance Comparison -async function testPerformanceComparison() { - const results = { - helius: {}, - standard: {} - }; - - // Test with Helius (mainnet-beta uses Helius) - const heliusStart = Date.now(); - const heliusResponse = await axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLET, - network: 'mainnet-beta', - tokenSymbols: ['SOL'] - }); - results.helius.duration = Date.now() - heliusStart; - results.helius.success = !!heliusResponse.data.balances; - - // Test with standard RPC (devnet uses standard) - const standardStart = Date.now(); - const standardResponse = await axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLET, - network: 'devnet', - tokenSymbols: ['SOL'] - }); - results.standard.duration = Date.now() - standardStart; - results.standard.success = !!standardResponse.data.balances; - - log(`Helius RPC: ${results.helius.duration}ms`, 'info'); - log(`Standard RPC: ${results.standard.duration}ms`, 'info'); - - const improvement = ((results.standard.duration - results.helius.duration) / results.standard.duration * 100).toFixed(1); - log(`Performance improvement: ${improvement}%`, 'info'); - - return results; -} - -// Main test runner -async function runTests() { - console.log('\n\x1b[1m\x1b[36m🚀 Helius Live Integration Tests\x1b[0m\n'); - console.log('\x1b[90mTesting Helius RPC provider with real API key...\x1b[0m\n'); - - // Check if server is running - try { - await axios.get(`${GATEWAY_URL}/`); - } catch (error) { - log('Gateway server is not running. Start it with: pnpm start --passphrase=test123 --dev', 'error'); - process.exit(1); - } - - // Run all tests - await testCase('Helius RPC Connection', testHeliusRPCConnection); - await testCase('Helius WebSocket Connection', testHeliusWebSocket); - await testCase('Balance Fetching via Helius', testBalanceFetching); - await testCase('Token List Loading', testTokenList); - await testCase('Priority Fee Estimation', testPriorityFeeEstimation); - await testCase('Direct Helius RPC Methods', testHeliusRPCMethods); - await testCase('Transaction Monitoring Setup', testTransactionMonitoring); - await testCase('Performance Comparison', testPerformanceComparison); - - // Print summary - console.log('\n\x1b[1m\x1b[36m📊 Test Summary\x1b[0m\n'); - console.log(`\x1b[32m✓ Passed: ${tests.passed}\x1b[0m`); - console.log(`\x1b[31m✗ Failed: ${tests.failed}\x1b[0m`); - console.log(`\x1b[34mTotal: ${tests.passed + tests.failed}\x1b[0m`); - - // Print detailed results - console.log('\n\x1b[1m\x1b[36m📋 Detailed Results\x1b[0m\n'); - tests.results.forEach(result => { - const icon = result.status === 'passed' ? '✓' : '✗'; - const color = result.status === 'passed' ? '\x1b[32m' : '\x1b[31m'; - const duration = result.duration ? ` (${result.duration}ms)` : ''; - const error = result.error ? ` - ${result.error}` : ''; - console.log(`${color}${icon} ${result.name}${duration}${error}\x1b[0m`); - }); - - // Exit code based on test results - process.exit(tests.failed > 0 ? 1 : 0); -} - -// Run tests -runTests().catch(error => { - log(`Fatal error: ${error.message}`, 'error'); - process.exit(1); -}); \ No newline at end of file diff --git a/scripts/test-helius-performance.js b/scripts/test-helius-performance.js deleted file mode 100644 index 4e18f7c606..0000000000 --- a/scripts/test-helius-performance.js +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env node - -/** - * Helius Performance Benchmark Script - * - * This script benchmarks Helius vs standard RPC performance across different operations. - * It measures latency, throughput, and reliability of both providers. - * - * Prerequisites: - * - Valid Helius API key configured in conf/rpc/helius.yml - * - Gateway server running: pnpm start --passphrase=test123 --dev - * - * Usage: node scripts/test-helius-performance.js [--iterations=10] [--concurrent=3] - */ - -const axios = require('axios'); -const { Connection, PublicKey, LAMPORTS_PER_SOL } = require('@solana/web3.js'); -const fs = require('fs'); -const yaml = require('js-yaml'); - -const GATEWAY_URL = 'http://localhost:15888'; -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; -const TEST_WALLETS = [ - 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD', - 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', - 'So11111111111111111111111111111111111111112' -]; - -// Parse command line arguments -const args = process.argv.slice(2); -const iterations = parseInt(args.find(arg => arg.startsWith('--iterations='))?.split('=')[1]) || 10; -const concurrent = parseInt(args.find(arg => arg.startsWith('--concurrent='))?.split('=')[1]) || 3; - -function log(message, type = 'info') { - const timestamp = new Date().toISOString(); - const prefix = { - info: '[INFO]', - success: '[✓]', - error: '[✗]', - warn: '[WARN]', - perf: '[PERF]' - }; - console.log(`${timestamp} ${prefix[type] || prefix.info} ${message}`); -} - -function calculateStats(times) { - const sorted = times.slice().sort((a, b) => a - b); - const avg = times.reduce((a, b) => a + b, 0) / times.length; - const median = sorted.length % 2 === 0 - ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 - : sorted[Math.floor(sorted.length / 2)]; - const min = Math.min(...times); - const max = Math.max(...times); - const p95 = sorted[Math.floor(sorted.length * 0.95)]; - const p99 = sorted[Math.floor(sorted.length * 0.99)]; - - return { avg, median, min, max, p95, p99 }; -} - -function printStats(name, stats) { - log(`${name} Performance Stats:`, 'perf'); - console.log(` Average: ${stats.avg.toFixed(1)}ms`); - console.log(` Median: ${stats.median.toFixed(1)}ms`); - console.log(` Min: ${stats.min.toFixed(1)}ms`); - console.log(` Max: ${stats.max.toFixed(1)}ms`); - console.log(` 95th: ${stats.p95.toFixed(1)}ms`); - console.log(` 99th: ${stats.p99.toFixed(1)}ms`); -} - -// Benchmark 1: Balance fetching performance -async function benchmarkBalanceFetching() { - log('Benchmarking balance fetching...', 'perf'); - - const heliusTimes = []; - const standardTimes = []; - const errors = { helius: 0, standard: 0 }; - - // Test Helius provider (mainnet-beta) - for (let i = 0; i < iterations; i++) { - try { - const start = Date.now(); - await axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLET, - network: 'mainnet-beta', - tokenSymbols: ['SOL'] - }); - heliusTimes.push(Date.now() - start); - } catch (error) { - errors.helius++; - log(`Helius error ${i}: ${error.message}`, 'error'); - } - } - - // Test standard provider (devnet) - for (let i = 0; i < iterations; i++) { - try { - const start = Date.now(); - await axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLET, - network: 'devnet', - tokenSymbols: ['SOL'] - }); - standardTimes.push(Date.now() - start); - } catch (error) { - errors.standard++; - log(`Standard error ${i}: ${error.message}`, 'error'); - } - } - - const heliusStats = calculateStats(heliusTimes); - const standardStats = calculateStats(standardTimes); - - printStats('Helius Balance Fetching', heliusStats); - printStats('Standard Balance Fetching', standardStats); - - const improvement = ((standardStats.avg - heliusStats.avg) / standardStats.avg * 100).toFixed(1); - log(`Performance improvement: ${improvement}%`, 'success'); - log(`Error rates - Helius: ${errors.helius}/${iterations}, Standard: ${errors.standard}/${iterations}`, 'info'); - - return { heliusStats, standardStats, errors, improvement }; -} - -// Benchmark 2: Token list loading performance -async function benchmarkTokenList() { - log('Benchmarking token list loading...', 'perf'); - - const heliusTimes = []; - const standardTimes = []; - - // Test Helius provider - for (let i = 0; i < Math.min(iterations, 5); i++) { // Fewer iterations for token list - const start = Date.now(); - await axios.post(`${GATEWAY_URL}/chains/solana/tokens`, { - network: 'mainnet-beta' - }); - heliusTimes.push(Date.now() - start); - } - - // Test standard provider - for (let i = 0; i < Math.min(iterations, 5); i++) { - const start = Date.now(); - await axios.post(`${GATEWAY_URL}/chains/solana/tokens`, { - network: 'devnet' - }); - standardTimes.push(Date.now() - start); - } - - const heliusStats = calculateStats(heliusTimes); - const standardStats = calculateStats(standardTimes); - - printStats('Helius Token List', heliusStats); - printStats('Standard Token List', standardStats); - - return { heliusStats, standardStats }; -} - -// Benchmark 3: Concurrent request handling -async function benchmarkConcurrentRequests() { - log(`Benchmarking concurrent requests (${concurrent} concurrent)...`, 'perf'); - - const testConcurrentRequests = async (network, label) => { - const times = []; - const errors = []; - - for (let batch = 0; batch < Math.floor(iterations / concurrent); batch++) { - const promises = []; - const batchStart = Date.now(); - - for (let i = 0; i < concurrent; i++) { - const promise = axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLETS[i % TEST_WALLETS.length], - network, - tokenSymbols: ['SOL'] - }).catch(error => { - errors.push(error.message); - return null; - }); - promises.push(promise); - } - - await Promise.all(promises); - times.push(Date.now() - batchStart); - } - - return { times, errors }; - }; - - const heliusResults = await testConcurrentRequests('mainnet-beta', 'Helius'); - const standardResults = await testConcurrentRequests('devnet', 'Standard'); - - const heliusStats = calculateStats(heliusResults.times); - const standardStats = calculateStats(standardResults.times); - - printStats(`Helius Concurrent (${concurrent} req/batch)`, heliusStats); - printStats(`Standard Concurrent (${concurrent} req/batch)`, standardStats); - - log(`Helius errors: ${heliusResults.errors.length}`, 'info'); - log(`Standard errors: ${standardResults.errors.length}`, 'info'); - - return { heliusStats, standardStats, errors: { helius: heliusResults.errors, standard: standardResults.errors } }; -} - -// Benchmark 4: Direct RPC call performance -async function benchmarkDirectRPC() { - log('Benchmarking direct RPC calls...', 'perf'); - - // Get Helius config - const configPath = './conf/rpc/helius.yml'; - if (!fs.existsSync(configPath)) { - log('Helius config not found, skipping direct RPC test', 'warn'); - return null; - } - - const config = yaml.load(fs.readFileSync(configPath, 'utf8')); - const apiKey = config.apiKey; - - const heliusRpcUrl = `https://mainnet.helius-rpc.com/?api-key=${apiKey}`; - const standardRpcUrl = 'https://api.mainnet-beta.solana.com'; - - const heliusConnection = new Connection(heliusRpcUrl, 'confirmed'); - const standardConnection = new Connection(standardRpcUrl, 'confirmed'); - - const testWallet = new PublicKey(TEST_WALLET); - - const heliusTimes = []; - const standardTimes = []; - - // Test Helius RPC - for (let i = 0; i < iterations; i++) { - try { - const start = Date.now(); - await heliusConnection.getBalance(testWallet); - heliusTimes.push(Date.now() - start); - } catch (error) { - log(`Helius RPC error: ${error.message}`, 'error'); - } - } - - // Test Standard RPC - for (let i = 0; i < iterations; i++) { - try { - const start = Date.now(); - await standardConnection.getBalance(testWallet); - standardTimes.push(Date.now() - start); - } catch (error) { - log(`Standard RPC error: ${error.message}`, 'error'); - } - } - - const heliusStats = calculateStats(heliusTimes); - const standardStats = calculateStats(standardTimes); - - printStats('Helius Direct RPC', heliusStats); - printStats('Standard Direct RPC', standardStats); - - const improvement = ((standardStats.avg - heliusStats.avg) / standardStats.avg * 100).toFixed(1); - log(`Direct RPC improvement: ${improvement}%`, 'success'); - - return { heliusStats, standardStats, improvement }; -} - -// Benchmark 5: Priority fee estimation performance -async function benchmarkPriorityFees() { - log('Benchmarking priority fee estimation...', 'perf'); - - const heliusTimes = []; - const standardTimes = []; - - // Test Helius provider - for (let i = 0; i < Math.min(iterations, 5); i++) { - const start = Date.now(); - await axios.get(`${GATEWAY_URL}/chains/solana/estimate-gas?network=mainnet-beta`); - heliusTimes.push(Date.now() - start); - } - - // Test standard provider - for (let i = 0; i < Math.min(iterations, 5); i++) { - const start = Date.now(); - await axios.get(`${GATEWAY_URL}/chains/solana/estimate-gas?network=devnet`); - standardTimes.push(Date.now() - start); - } - - const heliusStats = calculateStats(heliusTimes); - const standardStats = calculateStats(standardTimes); - - printStats('Helius Priority Fees', heliusStats); - printStats('Standard Priority Fees', standardStats); - - return { heliusStats, standardStats }; -} - -// Main benchmark runner -async function runBenchmarks() { - console.log('\n🏁 Helius Performance Benchmark Suite\n'); - console.log(`Running ${iterations} iterations with ${concurrent} concurrent requests...\n`); - - // Check if server is running - try { - await axios.get(`${GATEWAY_URL}/`); - } catch (error) { - log('Gateway server is not running. Start it with: pnpm start --passphrase=test123 --dev', 'error'); - process.exit(1); - } - - // Run all benchmarks - const results = {}; - - results.balanceFetching = await benchmarkBalanceFetching(); - console.log(''); - - results.tokenList = await benchmarkTokenList(); - console.log(''); - - results.concurrent = await benchmarkConcurrentRequests(); - console.log(''); - - results.directRPC = await benchmarkDirectRPC(); - if (results.directRPC) console.log(''); - - results.priorityFees = await benchmarkPriorityFees(); - console.log(''); - - // Summary report - console.log('📊 Performance Summary\n'); - - const improvements = []; - if (results.balanceFetching?.improvement) { - improvements.push(`Balance Fetching: ${results.balanceFetching.improvement}%`); - } - if (results.directRPC?.improvement) { - improvements.push(`Direct RPC: ${results.directRPC.improvement}%`); - } - - improvements.forEach(improvement => { - log(improvement, 'success'); - }); - - // Error summary - const totalHeliusErrors = (results.balanceFetching?.errors?.helius || 0) + - (results.concurrent?.errors?.helius?.length || 0); - const totalStandardErrors = (results.balanceFetching?.errors?.standard || 0) + - (results.concurrent?.errors?.standard?.length || 0); - - console.log(''); - log(`Total Helius errors: ${totalHeliusErrors}`, totalHeliusErrors > 0 ? 'warn' : 'success'); - log(`Total Standard errors: ${totalStandardErrors}`, totalStandardErrors > 0 ? 'warn' : 'success'); - - // Recommendations - console.log('\n💡 Recommendations\n'); - if (results.balanceFetching?.improvement > 0) { - console.log('✓ Helius shows better performance for balance fetching'); - } - if (results.directRPC?.improvement > 0) { - console.log('✓ Helius RPC endpoints are faster than standard'); - } - if (totalHeliusErrors === 0) { - console.log('✓ Helius provider shows good reliability'); - } - if (results.concurrent?.errors?.helius?.length < results.concurrent?.errors?.standard?.length) { - console.log('✓ Helius handles concurrent requests better'); - } - - console.log(''); - return results; -} - -// Run benchmarks -runBenchmarks().catch(error => { - log(`Fatal error: ${error.message}`, 'error'); - process.exit(1); -}); \ No newline at end of file diff --git a/scripts/test-infura-live.js b/scripts/test-infura-live.js deleted file mode 100755 index 152d66521b..0000000000 --- a/scripts/test-infura-live.js +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env node - -/** - * Live Infura Integration Testing Script - * - * This script tests the Infura RPC provider integration with a real API key. - * It verifies HTTP and WebSocket connections and RPC functionality. - * - * Prerequisites: - * - Valid Infura API key configured in conf/rpc/infura.yml - * - Gateway server running: pnpm start --passphrase=test123 --dev - * - * Usage: node scripts/test-infura-live.js - */ - -const axios = require('axios'); -const { ethers } = require('ethers'); - -const GATEWAY_URL = 'http://localhost:15888'; -const TEST_WALLET = '0x742d35Cc6634C0532925a3b8D66C2Fb4b03F31a0'; // Random Ethereum address - -// Test configuration -const tests = { - passed: 0, - failed: 0, - results: [] -}; - -// Helper functions (using simple console colors) -function log(message, type = 'info') { - const timestamp = new Date().toISOString(); - const colors = { - info: '\x1b[34m[INFO]\x1b[0m', // blue - success: '\x1b[32m[✓]\x1b[0m', // green - error: '\x1b[31m[✗]\x1b[0m', // red - warn: '\x1b[33m[WARN]\x1b[0m', // yellow - test: '\x1b[36m[TEST]\x1b[0m' // cyan - }; - console.log(`${timestamp} ${colors[type] || colors.info} ${message}`); -} - -async function testCase(name, fn) { - log(`Running: ${name}`, 'test'); - try { - const startTime = Date.now(); - await fn(); - const duration = Date.now() - startTime; - tests.passed++; - tests.results.push({ name, status: 'passed', duration }); - log(`✓ ${name} (${duration}ms)`, 'success'); - } catch (error) { - tests.failed++; - tests.results.push({ name, status: 'failed', error: error.message }); - log(`✗ ${name}: ${error.message}`, 'error'); - } -} - -/** - * Test Ethereum chain status endpoint - */ -async function testEthereumStatus() { - const response = await axios.get(`${GATEWAY_URL}/chains/ethereum/status?network=mainnet`); - - if (response.status !== 200) { - throw new Error(`Expected status 200, got ${response.status}`); - } - - const data = response.data; - if (!data.chainId || !data.blockNumber) { - throw new Error('Missing required fields in status response'); - } - - log(`Chain ID: ${data.chainId}, Block Number: ${data.blockNumber}`, 'info'); -} - -/** - * Test Ethereum balance endpoint with Infura - */ -async function testEthereumBalance() { - const response = await axios.get(`${GATEWAY_URL}/chains/ethereum/balances`, { - params: { - network: 'mainnet', - address: TEST_WALLET - } - }); - - if (response.status !== 200) { - throw new Error(`Expected status 200, got ${response.status}`); - } - - const data = response.data; - if (!data.balances) { - throw new Error('Missing balances in response'); - } - - log(`ETH Balance: ${data.balances.ETH || '0'} ETH`, 'info'); -} - -/** - * Test token list endpoint - */ -async function testEthereumTokens() { - const response = await axios.get(`${GATEWAY_URL}/chains/ethereum/tokens?network=mainnet`); - - if (response.status !== 200) { - throw new Error(`Expected status 200, got ${response.status}`); - } - - const data = response.data; - if (!Array.isArray(data.tokens) || data.tokens.length === 0) { - throw new Error('Expected non-empty tokens array'); - } - - log(`Loaded ${data.tokens.length} tokens`, 'info'); -} - -/** - * Test Polygon network with Infura - */ -async function testPolygonStatus() { - const response = await axios.get(`${GATEWAY_URL}/chains/ethereum/status?network=polygon`); - - if (response.status !== 200) { - throw new Error(`Expected status 200, got ${response.status}`); - } - - const data = response.data; - if (!data.chainId || data.chainId !== 137) { - throw new Error(`Expected Polygon chain ID 137, got ${data.chainId}`); - } - - log(`Polygon Chain ID: ${data.chainId}, Block Number: ${data.blockNumber}`, 'info'); -} - -/** - * Test Arbitrum network with Infura - */ -async function testArbitrumStatus() { - const response = await axios.get(`${GATEWAY_URL}/chains/ethereum/status?network=arbitrum`); - - if (response.status !== 200) { - throw new Error(`Expected status 200, got ${response.status}`); - } - - const data = response.data; - if (!data.chainId || data.chainId !== 42161) { - throw new Error(`Expected Arbitrum chain ID 42161, got ${data.chainId}`); - } - - log(`Arbitrum Chain ID: ${data.chainId}, Block Number: ${data.blockNumber}`, 'info'); -} - -/** - * Test Sepolia testnet (should use standard RPC, not Infura) - */ -async function testSepoliaStatus() { - const response = await axios.get(`${GATEWAY_URL}/chains/ethereum/status?network=sepolia`); - - if (response.status !== 200) { - throw new Error(`Expected status 200, got ${response.status}`); - } - - const data = response.data; - if (!data.chainId || data.chainId !== 11155111) { - throw new Error(`Expected Sepolia chain ID 11155111, got ${data.chainId}`); - } - - log(`Sepolia Chain ID: ${data.chainId}, Block Number: ${data.blockNumber}`, 'info'); -} - -/** - * Main test runner - */ -async function runTests() { - log('🚀 Starting Infura Integration Tests', 'info'); - log('Testing Infura RPC provider integration for Ethereum networks', 'info'); - - try { - // Test Ethereum mainnet with Infura - await testCase('Ethereum Mainnet Status (Infura)', testEthereumStatus); - await testCase('Ethereum Mainnet Balance (Infura)', testEthereumBalance); - await testCase('Ethereum Mainnet Tokens', testEthereumTokens); - - // Test other networks with Infura - await testCase('Polygon Status (Infura)', testPolygonStatus); - await testCase('Arbitrum Status (Infura)', testArbitrumStatus); - - // Test testnet with standard RPC - await testCase('Sepolia Status (Standard RPC)', testSepoliaStatus); - - } catch (error) { - log(`Fatal error: ${error.message}`, 'error'); - process.exit(1); - } - - // Print summary - log('', 'info'); - log('=== Test Summary ===', 'info'); - log(`Total tests: ${tests.passed + tests.failed}`, 'info'); - log(`Passed: ${tests.passed}`, tests.passed > 0 ? 'success' : 'info'); - log(`Failed: ${tests.failed}`, tests.failed > 0 ? 'error' : 'info'); - - if (tests.failed > 0) { - log('', 'info'); - log('Failed tests:', 'error'); - tests.results - .filter(r => r.status === 'failed') - .forEach(r => log(` - ${r.name}: ${r.error}`, 'error')); - } - - // Performance summary - const avgDuration = tests.results - .filter(r => r.status === 'passed') - .reduce((sum, r) => sum + r.duration, 0) / tests.passed; - - if (tests.passed > 0) { - log(`Average response time: ${avgDuration.toFixed(2)}ms`, 'info'); - } - - log('', 'info'); - if (tests.failed === 0) { - log('🎉 All tests passed! Infura integration is working correctly.', 'success'); - process.exit(0); - } else { - log('❌ Some tests failed. Check Infura configuration and API key.', 'error'); - process.exit(1); - } -} - -// Handle uncaught errors -process.on('uncaughtException', (error) => { - log(`Uncaught exception: ${error.message}`, 'error'); - process.exit(1); -}); - -process.on('unhandledRejection', (reason, promise) => { - log(`Unhandled rejection at ${promise}: ${reason}`, 'error'); - process.exit(1); -}); - -// Run the tests -runTests(); \ No newline at end of file diff --git a/scripts/test-provider-switching.js b/scripts/test-provider-switching.js deleted file mode 100644 index 54e22770d7..0000000000 --- a/scripts/test-provider-switching.js +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env node - -/** - * RPC Provider Switching Test Script - * - * This script tests the dynamic switching between URL and Helius providers. - * It modifies network configurations and verifies the Gateway adapts correctly. - * - * Prerequisites: - * - Gateway server running: pnpm start --passphrase=test123 --dev - * - Valid Helius API key in conf/rpc/helius.yml - * - * Usage: node scripts/test-provider-switching.js - */ - -const axios = require('axios'); -const fs = require('fs'); -const yaml = require('js-yaml'); - -const GATEWAY_URL = 'http://localhost:15888'; -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; -const DEVNET_CONFIG = './conf/chains/solana/devnet.yml'; -const MAINNET_CONFIG = './conf/chains/solana/mainnet-beta.yml'; - -// Test tracking -const tests = { passed: 0, failed: 0, results: [] }; - -function log(message, type = 'info') { - const timestamp = new Date().toISOString(); - const prefix = { - info: '[INFO]', - success: '[✓]', - error: '[✗]', - warn: '[WARN]', - test: '[TEST]' - }; - console.log(`${timestamp} ${prefix[type] || prefix.info} ${message}`); -} - -async function testCase(name, fn) { - log(`Running: ${name}`, 'test'); - try { - const startTime = Date.now(); - await fn(); - const duration = Date.now() - startTime; - tests.passed++; - tests.results.push({ name, status: 'passed', duration }); - log(`${name} - PASSED (${duration}ms)`, 'success'); - return true; - } catch (error) { - tests.failed++; - tests.results.push({ name, status: 'failed', error: error.message }); - log(`${name} - FAILED: ${error.message}`, 'error'); - return false; - } -} - -// Helper functions -function readConfig(configPath) { - if (!fs.existsSync(configPath)) { - throw new Error(`Config file not found: ${configPath}`); - } - return yaml.load(fs.readFileSync(configPath, 'utf8')); -} - -function writeConfig(configPath, config) { - fs.writeFileSync(configPath, yaml.dump(config, { quotingType: '"' })); -} - -function backupConfig(configPath) { - const backupPath = `${configPath}.backup`; - fs.copyFileSync(configPath, backupPath); - return backupPath; -} - -function restoreConfig(configPath, backupPath) { - fs.copyFileSync(backupPath, configPath); - fs.unlinkSync(backupPath); -} - -async function waitForServerRestart(timeout = 10000) { - log('Waiting for server to restart with new config...', 'info'); - await new Promise(resolve => setTimeout(resolve, 3000)); // Give server time to reload config - - const start = Date.now(); - while (Date.now() - start < timeout) { - try { - await axios.get(`${GATEWAY_URL}/`, { timeout: 1000 }); - log('Server is ready', 'success'); - return; - } catch (error) { - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - throw new Error('Server did not restart within timeout'); -} - -// Test 1: Verify Current Provider Configuration -async function testCurrentProviderConfig() { - const devnetConfig = readConfig(DEVNET_CONFIG); - const mainnetConfig = readConfig(MAINNET_CONFIG); - - log(`Devnet provider: ${devnetConfig.rpcProvider || 'url (default)'}`, 'info'); - log(`Mainnet provider: ${mainnetConfig.rpcProvider || 'url (default)'}`, 'info'); - - // Test devnet status - const devnetStatus = await axios.post(`${GATEWAY_URL}/chains/solana/status`, { - network: 'devnet' - }); - - // Test mainnet status - const mainnetStatus = await axios.post(`${GATEWAY_URL}/chains/solana/status`, { - network: 'mainnet-beta' - }); - - log(`Devnet RPC URL: ${devnetStatus.data.rpcUrl}`, 'info'); - log(`Mainnet RPC URL: ${mainnetStatus.data.rpcUrl}`, 'info'); - - return { devnetConfig, mainnetConfig, devnetStatus: devnetStatus.data, mainnetStatus: mainnetStatus.data }; -} - -// Test 2: Switch Devnet from URL to Helius -async function testSwitchDevnetToHelius() { - const backupPath = backupConfig(DEVNET_CONFIG); - - try { - // Modify devnet config to use Helius - const config = readConfig(DEVNET_CONFIG); - config.rpcProvider = 'helius'; - writeConfig(DEVNET_CONFIG, config); - - log('Switched devnet to Helius provider', 'info'); - log('Note: You need to restart the server to apply changes', 'warn'); - - // For this test, we'll just verify the config change - const updatedConfig = readConfig(DEVNET_CONFIG); - if (updatedConfig.rpcProvider !== 'helius') { - throw new Error('Config update failed'); - } - - log('Config successfully updated', 'success'); - - } finally { - // Restore original config - restoreConfig(DEVNET_CONFIG, backupPath); - } -} - -// Test 3: Switch Mainnet from Helius to URL -async function testSwitchMainnetToURL() { - const backupPath = backupConfig(MAINNET_CONFIG); - - try { - // Modify mainnet config to use URL - const config = readConfig(MAINNET_CONFIG); - config.rpcProvider = 'url'; - writeConfig(MAINNET_CONFIG, config); - - log('Switched mainnet to URL provider', 'info'); - log('Note: You need to restart the server to apply changes', 'warn'); - - // Verify config change - const updatedConfig = readConfig(MAINNET_CONFIG); - if (updatedConfig.rpcProvider !== 'url') { - throw new Error('Config update failed'); - } - - log('Config successfully updated', 'success'); - - } finally { - // Restore original config - restoreConfig(MAINNET_CONFIG, backupPath); - } -} - -// Test 4: Test Invalid Provider Configuration -async function testInvalidProvider() { - const backupPath = backupConfig(DEVNET_CONFIG); - - try { - // Set invalid provider - const config = readConfig(DEVNET_CONFIG); - config.rpcProvider = 'invalid-provider'; - writeConfig(DEVNET_CONFIG, config); - - log('Set invalid provider type', 'info'); - - // Verify config file has invalid value - const updatedConfig = readConfig(DEVNET_CONFIG); - if (updatedConfig.rpcProvider !== 'invalid-provider') { - throw new Error('Config update failed'); - } - - log('Invalid provider config created (server should reject this)', 'info'); - - } finally { - // Restore original config - restoreConfig(DEVNET_CONFIG, backupPath); - } -} - -// Test 5: Test Missing Helius Configuration -async function testMissingHeliusConfig() { - const heliusConfigPath = './conf/rpc/helius.yml'; - let backupPath = null; - - if (fs.existsSync(heliusConfigPath)) { - backupPath = `${heliusConfigPath}.backup`; - fs.copyFileSync(heliusConfigPath, backupPath); - } - - const mainnetBackupPath = backupConfig(MAINNET_CONFIG); - - try { - // Remove helius.yml temporarily - if (fs.existsSync(heliusConfigPath)) { - fs.unlinkSync(heliusConfigPath); - } - - // Set mainnet to use helius (should fail) - const config = readConfig(MAINNET_CONFIG); - config.rpcProvider = 'helius'; - writeConfig(MAINNET_CONFIG, config); - - log('Removed Helius config file and set mainnet to use Helius', 'info'); - log('Server should fallback or show error on restart', 'warn'); - - } finally { - // Restore configs - if (backupPath) { - fs.copyFileSync(backupPath, heliusConfigPath); - fs.unlinkSync(backupPath); - } - restoreConfig(MAINNET_CONFIG, mainnetBackupPath); - } -} - -// Test 6: Test Performance Between Providers -async function testProviderPerformance() { - const results = { - url: { times: [], average: 0 }, - helius: { times: [], average: 0 } - }; - - // Test URL provider (devnet) multiple times - for (let i = 0; i < 3; i++) { - const start = Date.now(); - await axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLET, - network: 'devnet', - tokenSymbols: ['SOL'] - }); - results.url.times.push(Date.now() - start); - } - - // Test Helius provider (mainnet) multiple times - for (let i = 0; i < 3; i++) { - const start = Date.now(); - await axios.post(`${GATEWAY_URL}/chains/solana/balances`, { - wallet: TEST_WALLET, - network: 'mainnet-beta', - tokenSymbols: ['SOL'] - }); - results.helius.times.push(Date.now() - start); - } - - // Calculate averages - results.url.average = results.url.times.reduce((a, b) => a + b, 0) / results.url.times.length; - results.helius.average = results.helius.times.reduce((a, b) => a + b, 0) / results.helius.times.length; - - log(`URL Provider (devnet) average: ${results.url.average.toFixed(1)}ms`, 'info'); - log(`Helius Provider (mainnet) average: ${results.helius.average.toFixed(1)}ms`, 'info'); - - const improvement = ((results.url.average - results.helius.average) / results.url.average * 100).toFixed(1); - log(`Performance difference: ${improvement}%`, 'info'); - - return results; -} - -// Test 7: Test Configuration Schema Validation -async function testConfigurationSchema() { - // Test valid provider values - const validProviders = ['url', 'helius']; - const backupPath = backupConfig(DEVNET_CONFIG); - - try { - for (const provider of validProviders) { - const config = readConfig(DEVNET_CONFIG); - config.rpcProvider = provider; - writeConfig(DEVNET_CONFIG, config); - - // Verify it was written correctly - const updatedConfig = readConfig(DEVNET_CONFIG); - if (updatedConfig.rpcProvider !== provider) { - throw new Error(`Failed to set provider to ${provider}`); - } - - log(`Successfully set provider to: ${provider}`, 'info'); - } - } finally { - restoreConfig(DEVNET_CONFIG, backupPath); - } -} - -// Main test runner -async function runTests() { - console.log('\n🔄 RPC Provider Switching Tests\n'); - console.log('Testing dynamic provider switching between URL and Helius...\n'); - - // Check if server is running - try { - await axios.get(`${GATEWAY_URL}/`); - } catch (error) { - log('Gateway server is not running. Start it with: pnpm start --passphrase=test123 --dev', 'error'); - process.exit(1); - } - - // Run all tests - await testCase('Current Provider Configuration', testCurrentProviderConfig); - await testCase('Switch Devnet to Helius', testSwitchDevnetToHelius); - await testCase('Switch Mainnet to URL', testSwitchMainnetToURL); - await testCase('Invalid Provider Configuration', testInvalidProvider); - await testCase('Missing Helius Configuration', testMissingHeliusConfig); - await testCase('Provider Performance Comparison', testProviderPerformance); - await testCase('Configuration Schema Validation', testConfigurationSchema); - - // Print summary - console.log('\n📊 Test Summary\n'); - console.log(`✓ Passed: ${tests.passed}`); - console.log(`✗ Failed: ${tests.failed}`); - console.log(`Total: ${tests.passed + tests.failed}`); - - // Print detailed results - console.log('\n📋 Detailed Results\n'); - tests.results.forEach(result => { - const icon = result.status === 'passed' ? '✓' : '✗'; - const duration = result.duration ? ` (${result.duration}ms)` : ''; - const error = result.error ? ` - ${result.error}` : ''; - console.log(`${icon} ${result.name}${duration}${error}`); - }); - - console.log('\n⚠️ Note: Some tests modify configs temporarily'); - console.log('Server restart may be required to fully test provider switching\n'); - - // Exit code based on test results - process.exit(tests.failed > 0 ? 1 : 0); -} - -// Run tests -runTests().catch(error => { - log(`Fatal error: ${error.message}`, 'error'); - process.exit(1); -}); \ No newline at end of file diff --git a/src/@types/fastify.d.ts b/src/@types/fastify.d.ts index bef2d45088..2d4db86b86 100644 --- a/src/@types/fastify.d.ts +++ b/src/@types/fastify.d.ts @@ -1,10 +1,10 @@ -import { FastifyPluginCallback } from 'fastify' +import { FastifyPluginCallback } from 'fastify'; declare module 'fastify' { export type FastifyPluginAsync = FastifyPluginCallback; - + interface FastifySchema { swaggerQueryExample?: Record; 'x-examples'?: Record; } -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index 5ef6fe21dd..25ad9d4598 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,5 @@ // External dependencies -import { spawn } from 'child_process'; -import { exec } from 'child_process'; +import { spawn, exec } from 'child_process'; import { promisify } from 'util'; import fastifyRateLimit from '@fastify/rate-limit'; @@ -13,24 +12,19 @@ import Fastify, { FastifyInstance } from 'fastify'; // Internal dependencies // Routes -import { ethereumRoutes } from './chains/ethereum/ethereum.routes'; -import { solanaRoutes } from './chains/solana/solana.routes'; +import { chainRoutes } from './chains/chain.routes'; +import * as ethereumSchemas from './chains/ethereum/schemas'; import { configRoutes } from './config/config.routes'; -import { register0xRoutes } from './connectors/0x/0x.routes'; -import { dflowRoutes } from './connectors/dflow/dflow.routes'; -import { jupiterRoutes } from './connectors/jupiter/jupiter.routes'; -import { meteoraRoutes } from './connectors/meteora/meteora.routes'; -import { okxRoutes } from './connectors/okx/okx.routes'; -import { orcaRoutes } from './connectors/orca/orca.routes'; -import { pancakeswapRoutes } from './connectors/pancakeswap/pancakeswap.routes'; -import { pancakeswapSolRoutes } from './connectors/pancakeswap-sol/pancakeswap-sol.routes'; -import { raydiumRoutes } from './connectors/raydium/raydium.routes'; -import { titanRoutes } from './connectors/titan/titan.routes'; -import { uniswapRoutes } from './connectors/uniswap/uniswap.routes'; import { getHttpsOptions } from './https'; import { rootPath } from './paths'; import { poolRoutes } from './pools/pools.routes'; +import * as ammSchemas from './schemas/amm-schema'; +import * as chainSchemas from './schemas/chain-schema'; +import * as clmmSchemas from './schemas/clmm-schema'; +import * as errorSchemas from './schemas/error-schema'; +import * as routerSchemas from './schemas/router-schema'; import { ConfigManagerV2 } from './services/config-manager-v2'; +import { httpErrors } from './services/error-handler'; import { constantTimeEqual, extractBearerToken, @@ -42,11 +36,19 @@ import { loadOrCreateApiKey, } from './services/gateway-security'; import { logger } from './services/logger'; -import { quoteCache } from './services/quote-cache'; +import { OPERATION_IDS } from './services/operation-ids'; +import { ajvOptions, schemaErrorFormatter } from './services/schema-keywords'; import { displayChainConfigurations } from './services/startup-banner'; +import * as tokenSchemas from './tokens/schemas'; import { tokensRoutes } from './tokens/tokens.routes'; -import { tradingRoutes, tradingClmmRoutes, tradingAmmRoutes } from './trading/trading.routes'; +import * as clmmReadRouteSchemas from './trading/clmm'; +import * as poolSwapRoutes from './trading/pool-swap-routes'; +import * as ammRouteSchemas from './trading/trading-amm-routes'; +import * as clmmRouteSchemas from './trading/trading-clmm-routes'; +import * as routerRouteSchemas from './trading/trading-router-routes'; +import { tradingRouterRoutes, tradingClmmRoutes, tradingAmmRoutes } from './trading/trading.routes'; import { GATEWAY_VERSION } from './version'; +import * as walletSchemas from './wallet/schemas'; import { walletRoutes } from './wallet/wallet.routes'; import { asciiLogo } from './index'; @@ -61,6 +63,136 @@ const devMode = process.argv.includes('--dev') || process.env.GATEWAY_TEST_MODE // Promisify exec for async/await usage const execPromise = promisify(exec); +/** + * Collect every `$id`-carrying schema reachable from `node`, itself included. + * + * The search continues *through* a schema it has already collected, because `$id`s + * nest: each write response names its confirmed-transaction `data` object, and those + * only ever appear inside their parent. Collecting the parent alone would leave the + * ref `refIdentifiedSchemas` writes for that child pointing at a component nobody + * defined — a spec that resolves nowhere. + */ +const collectIdentifiedSchemas = (node: any, found: Map>): void => { + if (Array.isArray(node)) { + node.forEach((item) => collectIdentifiedSchemas(item, found)); + return; + } + if (node === null || typeof node !== 'object') return; + if (typeof node.$id === 'string' && !found.has(node.$id)) found.set(node.$id, node); + Object.values(node).forEach((value) => collectIdentifiedSchemas(value, found)); +}; + +/** + * Every schema carrying an `$id`, collected from the shared schema modules and from the + * route modules that declare their own request bodies. + * + * Registering these with `addSchema` is what puts them in the spec's + * `components.schemas`; `refIdentifiedSchemas` below then points the routes at them. + * `$id`s must be unique across all modules — Fastify rejects a duplicate — which is + * why the AMM copies of the names CLMM also uses carry an `Amm` prefix. + * + * The route modules are here because the shapes in `./schemas` are the *base* types the + * unified routes compose from, not what a caller sends: they predate the refactor, so + * they carry a per-connector `network` and no `connector` or `chainNetwork`. Generating + * a client from those alone produced request models that were wrong the same way for + * every route, so each route's own body now carries the `$id` instead. + */ +const identifiedSchemas = (): Array> => { + const found = new Map>(); + for (const module of [ + ammSchemas, + chainSchemas, + clmmSchemas, + routerSchemas, + ammRouteSchemas, + clmmRouteSchemas, + clmmReadRouteSchemas, + routerRouteSchemas, + poolSwapRoutes, + ethereumSchemas, + walletSchemas, + tokenSchemas, + errorSchemas, + ]) { + for (const value of Object.values(module)) { + if (typeof value === 'object' && value !== null) collectIdentifiedSchemas(value, found); + } + } + return [...found.values()].map((value) => { + // Ref the schema's own $id'd children too, keeping only its root $id. Registering + // a parent with its children inlined emits both the child component and an + // anonymous copy inside the parent, which is what a generated client names Data1, + // Data2, ... — numbered by traversal order, so they churn on any insertion. + const { $id, ...rest } = Type.Strict(value as any) as Record; + return { $id, ...refIdentifiedSchemas(rest, 'openapi') }; + }); +}; + +/** + * Replace inline schema objects that carry an `$id` with a `$ref` to the component. + * + * Fastify inlines whatever a route declares, so without this every operation restates + * its schemas in full: the spec had no `components.schemas` at all, identical shapes + * (CLMM and AMM execute-swap, say) appeared as separate anonymous objects, and a + * generated client got names derived from route paths rather than the domain — which + * change whenever a route is renamed. + * + * This runs only while the spec document is built. Route validation and serialization + * keep using the compiled inline schemas, so nothing about request handling changes. + * + * Two ref forms are needed, because the two places refs appear are processed + * differently. Route schemas go through @fastify/swagger's transform, which rewrites + * Fastify's own "Id#" form into "#/components/schemas/Id" — passing the components path + * there instead makes it read the ref as local to the route schema and fail to resolve + * it. Registered components are emitted verbatim, so they must already carry the + * components path. + */ +type RefStyle = 'fastify' | 'openapi'; + +const refIdentifiedSchemas = (node: any, style: RefStyle = 'fastify'): any => { + if (Array.isArray(node)) return node.map((item) => refIdentifiedSchemas(item, style)); + if (node === null || typeof node !== 'object') return node; + if (typeof node.$id === 'string') { + return { $ref: style === 'fastify' ? `${node.$id}#` : `#/components/schemas/${node.$id}` }; + } + return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, refIdentifiedSchemas(value, style)])); +}; + +/** + * Give an operation the two things a generated client needs and Gateway never stated: a + * stable name, and the shape of a failure. + * + * Both are applied here rather than in 56 route files. The name comes from the table in + * `operation-ids.ts` — chosen, not derived, so renaming a path does not rename a caller's + * method. The failure shape is the same envelope on every route, so listing it per route + * would only be a list to forget to update. + * + * 400 and 500 are declared everywhere because both are reachable everywhere: Fastify + * answers 400 for any request its schema rejects, and `rethrowRouteError` turns anything + * without a status of its own into a 500. A route that already declares a status keeps + * what it declared. + */ +const describeOperation = (schema: any, url: string, route: any): any => { + if (!schema || schema.hide) return schema; + + const method = Array.isArray(route?.method) ? route.method[0] : route?.method; + // Fastify spells a path parameter `:name`; the table is keyed the way the spec renders + // it. Without this the 14 parameterised routes silently keep no name at all. + const specPath = url.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + const operationId = OPERATION_IDS[`${method} ${specPath}`]; + const errorRef = { $ref: 'ErrorResponse#' }; + + return { + ...schema, + ...(operationId && !schema.operationId ? { operationId } : {}), + response: { + 400: errorRef, + 500: errorRef, + ...(schema.response ?? {}), + }, + }; +}; + const swaggerOptions = { openapi: { info: { @@ -74,67 +206,14 @@ const swaggerOptions = { }, ], tags: [ - // Main categories { name: '/config', description: 'System configuration endpoints' }, { name: '/wallet', description: 'Wallet management endpoints' }, { name: '/tokens', description: 'Token management endpoints' }, { name: '/pools', description: 'Pool management endpoints' }, - { name: '/trading/swap', description: 'Unified cross-chain swap endpoints' }, - { name: '/trading/clmm', description: 'Unified cross-chain CLMM (Concentrated Liquidity) endpoints' }, - { name: '/trading/amm', description: 'Unified cross-connector AMM endpoints (pool creation)' }, - - // Chains - { - name: '/chain/solana', - description: 'Solana and SVM-based chain endpoints', - }, - { - name: '/chain/ethereum', - description: 'Ethereum and EVM-based chain endpoints', - }, - - // Connectors - { - name: '/connector/jupiter', - description: 'Jupiter connector endpoints', - }, - { - name: '/connector/meteora', - description: 'Meteora connector endpoints', - }, - { - name: '/connector/orca', - description: 'Orca connector endpoints', - }, - { - name: '/connector/raydium', - description: 'Raydium connector endpoints', - }, - { - name: '/connector/uniswap', - description: 'Uniswap connector endpoints', - }, - { name: '/connector/0x', description: '0x connector endpoints' }, - { - name: '/connector/pancakeswap-sol', - description: 'PancakeSwap Solana connector endpoints', - }, - { - name: '/connector/pancakeswap', - description: 'PancakeSwap EVM connector endpoints', - }, - { - name: '/connector/dflow', - description: 'DFlow connector endpoints', - }, - { - name: '/connector/okx', - description: 'OKX DEX aggregator connector endpoints', - }, - { - name: '/connector/titan', - description: 'Titan connector endpoints', - }, + { name: '/chains', description: 'Chain endpoints, parameterized by chain' }, + { name: '/trading/router', description: 'Swaps routed across pools by a router connector' }, + { name: '/trading/clmm', description: 'Concentrated-liquidity pools: swaps, positions, and pool management' }, + { name: '/trading/amm', description: 'Constant-product pools: swaps, liquidity, and pool management' }, ], components: { parameters: { @@ -148,16 +227,22 @@ const swaggerOptions = { }, }, }, - transform: ({ schema, url }) => { + transform: ({ schema, url, route }: any) => { try { - return { - schema: schema ? Type.Strict(schema) : schema, - url: url, - }; + const transformed = schema ? refIdentifiedSchemas(Type.Strict(schema)) : schema; + return { schema: describeOperation(transformed, url, route), url }; } catch (error) { return { schema, url }; } }, + // Name components by their $id. Without this @fastify/swagger numbers them def-0, + // def-1, ... in registration order, so every component is renamed whenever a schema + // is added or removed — which is precisely the churn components exist to avoid. + refResolver: { + buildLocalReference(json: any, _baseUri: unknown, _fragment: unknown, i: number) { + return json.$id || `def-${i}`; + }, + }, hideUntagged: true, exposeRoute: true, }; @@ -181,11 +266,13 @@ const configureGatewayServer = () => { } : false, https: devMode ? undefined : getHttpsOptions(), + ajv: ajvOptions, + schemaErrorFormatter, }); const docsPort = ConfigManagerV2.getInstance().get('server.docsPort'); - docsServer = docsPort ? Fastify() : null; + docsServer = docsPort ? Fastify({ ajv: ajvOptions, schemaErrorFormatter }) : null; // Register TypeBox provider server.withTypeProvider(); @@ -237,10 +324,52 @@ const configureGatewayServer = () => { }); } + // `x-connectors` marks a field as belonging to particular venues — `configAddress` is + // meteora's, `ammConfigIndex` is raydium's — and it was documentation only: passing the + // wrong one created a pool with the connector's defaults instead of erroring, because + // Gateway destructures the keys it knows and ignores the rest. + // + // preValidation, deliberately: it runs on the body as the caller sent it, before AJV + // fills defaults. Checking afterwards would reject every 0x quote, since + // `approximateIfNoExactOut` defaults to true and is marked for the Solana routers. + server.addHook('preValidation', async (request) => { + const schema = (request as any).routeOptions?.schema; + if (!schema) return; + + for (const [part, sent] of [ + [schema.body, request.body], + [schema.querystring, request.query], + ] as [any, any][]) { + if (!part?.properties || !sent || typeof sent !== 'object') continue; + + const connector = sent.connector ?? part.properties.connector?.default; + if (!connector) continue; + + for (const [field, spec] of Object.entries(part.properties)) { + const connectors: string[] | undefined = spec?.['x-connectors']; + if (!connectors || sent[field] === undefined) continue; + if (!connectors.includes(connector)) { + throw httpErrors.badRequest( + `${field} is not a ${connector} parameter — it applies to ${connectors.join(', ')}. ` + + 'Remove it, or name a connector that takes it.', + ); + } + } + } + }); + // Serve Swagger/OpenAPI docs only on loopback (or when explicitly enabled). An exposed // /docs hands an attacker the full route map of fund-handling endpoints. (#652) const exposeDocs = !isExposedHost(getBindAddress()) || process.env.GATEWAY_ENABLE_DOCS === 'true'; if (exposeDocs) { + // Publish the $id'd schemas as spec components before Swagger builds the document. + // Routes keep their inline schemas for validation; this only gives the spec somewhere + // for refIdentifiedSchemas to point, so a generated client gets stable, domain names. + for (const schema of identifiedSchemas()) { + server.addSchema(schema); + docsServer?.addSchema(schema); + } + // Register Swagger server.register(fastifySwagger, swaggerOptions); @@ -291,71 +420,13 @@ const configureGatewayServer = () => { // Register pool routes app.register(poolRoutes, { prefix: '/pools' }); - // Register trading routes (unified cross-chain swap) - app.register(tradingRoutes, { prefix: '/trading/swap' }); - - // Register trading CLMM routes (unified cross-chain concentrated liquidity) + // Unified trading routes: the type lives in the path, the connector is a parameter. + app.register(tradingRouterRoutes, { prefix: '/trading/router' }); app.register(tradingClmmRoutes, { prefix: '/trading/clmm' }); - - // Register trading AMM routes (unified cross-connector AMM: pool creation) app.register(tradingAmmRoutes, { prefix: '/trading/amm' }); - // Register chain routes - app.register(solanaRoutes, { prefix: '/chains/solana' }); - app.register(ethereumRoutes, { prefix: '/chains/ethereum' }); - - // Register DEX connector routes - organized by connector - - // Jupiter routes - app.register(jupiterRoutes.router, { - prefix: '/connectors/jupiter/router', - }); - - // DFlow routes - app.register(dflowRoutes.router, { - prefix: '/connectors/dflow/router', - }); - - // OKX DEX aggregator routes - app.register(okxRoutes.router, { - prefix: '/connectors/okx/router', - }); - - // Titan routes - app.register(titanRoutes.router, { - prefix: '/connectors/titan/router', - }); - - // Meteora routes - app.register(meteoraRoutes.clmm, { prefix: '/connectors/meteora/clmm' }); - app.register(meteoraRoutes.amm, { prefix: '/connectors/meteora/amm' }); - - // // Orca routes - app.register(orcaRoutes.clmm, { prefix: '/connectors/orca/clmm' }); - - // Raydium routes - app.register(raydiumRoutes.amm, { prefix: '/connectors/raydium/amm' }); - app.register(raydiumRoutes.clmm, { prefix: '/connectors/raydium/clmm' }); - - // Uniswap routes - app.register(uniswapRoutes.router, { - prefix: '/connectors/uniswap/router', - }); - app.register(uniswapRoutes.amm, { prefix: '/connectors/uniswap/amm' }); - app.register(uniswapRoutes.clmm, { prefix: '/connectors/uniswap/clmm' }); - - // 0x routes - app.register(register0xRoutes); - - // Pancakeswap routes - app.register(pancakeswapRoutes.router, { - prefix: '/connectors/pancakeswap/router', - }); - app.register(pancakeswapRoutes.amm, { prefix: '/connectors/pancakeswap/amm' }); - app.register(pancakeswapRoutes.clmm, { prefix: '/connectors/pancakeswap/clmm' }); - - // PancakeSwap Solana routes - app.register(pancakeswapSolRoutes, { prefix: '/connectors/pancakeswap-sol' }); + // Chain routes, parameterized by chain (/chains/:chain/...). + app.register(chainRoutes, { prefix: '/chains' }); }; // Register routes on main server diff --git a/src/chains/chain.routes.ts b/src/chains/chain.routes.ts new file mode 100644 index 0000000000..1081aeba7d --- /dev/null +++ b/src/chains/chain.routes.ts @@ -0,0 +1,274 @@ +/** + * Chain routes, parameterized by chain. + * + * These used to be registered once per chain (/chains/solana/poll, + * /chains/ethereum/poll, ...), which made the route table grow with every chain + * and forced any client that wanted to be chain-agnostic — or any generated + * client — to hardcode the chain list. The handlers were already chain-agnostic: + * both chains' operations return the same shared response schemas, so the only + * thing per-chain about them is which function to call. + * + * EVM-only operations (allowances, approve) stay on /chains/ethereum: a + * parameterized route that 400s for Solana would be worse than a path that + * simply does not exist there. + */ +import sensible from '@fastify/sensible'; +import { Type } from '@sinclair/typebox'; +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; + +import { + BalanceRequestSchema, + BalanceRequestType, + BalanceResponseSchema, + EstimateGasRequestSchema, + EstimateGasRequestType, + EstimateGasResponseSchema, + PollRequestSchema, + PollRequestType, + PollResponseSchema, + StatusRequestSchema, + StatusRequestType, + StatusResponseSchema, + UnwrapRequestSchema, + UnwrapRequestType, + WrapRequestSchema, + WrapRequestType, + WrapResponseSchema, +} from '../schemas/chain-schema'; +import { httpErrors } from '../services/error-handler'; + +import { + getEthereumChainConfig, + getEthereumNetworkConfig, + networks as ethereumNetworks, +} from './ethereum/ethereum.config'; +import { allowancesRoute } from './ethereum/routes/allowances'; +import { approveRoute } from './ethereum/routes/approve'; +import { getEthereumBalances } from './ethereum/routes/balances'; +import { estimateGasEthereum } from './ethereum/routes/estimate-gas'; +import { pollEthereumTransaction } from './ethereum/routes/poll'; +import { getEthereumStatus } from './ethereum/routes/status'; +import { unwrapEthereum } from './ethereum/routes/unwrap'; +import { wrapEthereum } from './ethereum/routes/wrap'; +import { getSolanaBalances } from './solana/routes/balances'; +import { estimateGasSolana } from './solana/routes/estimate-gas'; +import { pollSolanaTransaction } from './solana/routes/poll'; +import { getSolanaStatus } from './solana/routes/status'; +import { unwrapSolana } from './solana/routes/unwrap'; +import { wrapSolana } from './solana/routes/wrap'; +import { getSolanaChainConfig, getSolanaNetworkConfig, networks as solanaNetworks } from './solana/solana.config'; + +interface ChainOps { + networks: readonly string[]; + defaultNetwork: () => string; + status: (fastify: FastifyInstance, network: string) => Promise; + estimateGas: (fastify: FastifyInstance, network: string) => Promise; + balances: (fastify: FastifyInstance, network: string, address: string, tokens?: string[]) => Promise; + poll: (fastify: FastifyInstance, network: string, signature: string) => Promise; + wrap: (fastify: FastifyInstance, network: string, address: string, amount: string) => Promise; + unwrap: (fastify: FastifyInstance, network: string, address: string, amount?: string) => Promise; + /** EVM chains require an explicit unwrap amount; Solana unwraps the full balance. */ + unwrapRequiresAmount: boolean; +} + +const CHAINS: Record = { + solana: { + networks: solanaNetworks, + defaultNetwork: () => getSolanaChainConfig().defaultNetwork, + status: (fastify, network) => getSolanaStatus(fastify, network), + estimateGas: (_fastify, network) => estimateGasSolana(network), + balances: (fastify, network, address, tokens) => getSolanaBalances(fastify, network, address, tokens), + poll: (fastify, network, signature) => pollSolanaTransaction(fastify, network, signature), + wrap: (fastify, network, address, amount) => wrapSolana(fastify, network, address, amount), + unwrap: (fastify, network, address, amount) => unwrapSolana(fastify, network, address, amount), + unwrapRequiresAmount: false, + }, + ethereum: { + networks: ethereumNetworks, + defaultNetwork: () => getEthereumChainConfig().defaultNetwork, + status: (_fastify, network) => getEthereumStatus(network), + estimateGas: (fastify, network) => estimateGasEthereum(fastify, network), + balances: (fastify, network, address, tokens) => getEthereumBalances(fastify, network, address, tokens), + poll: (fastify, network, signature) => pollEthereumTransaction(fastify, network, signature), + wrap: (fastify, network, address, amount) => wrapEthereum(fastify, network, address, amount), + unwrap: (fastify, network, address, amount) => unwrapEthereum(fastify, network, address, amount!), + unwrapRequiresAmount: true, + }, +}; + +export const SUPPORTED_CHAINS = Object.keys(CHAINS); + +/** + * The `chain` path parameter, enum-constrained so Swagger renders it as a dropdown + * rather than a free-text box and an unknown chain is rejected at the schema. Built + * from CHAINS, so adding a chain adds it to the docs. The default is documentation + * only — a path parameter is always present, so nothing is ever injected for it. + */ +const ChainParamsSchema = Type.Object({ + chain: Type.String({ + description: 'Chain to operate on', + enum: SUPPORTED_CHAINS, + default: 'solana', + }), +}); + +/** + * Resolve the chain and network for a request, rejecting an unknown chain and a + * network that belongs to a different one. The per-chain routes got the second + * check for free from their network enums; parameterizing the path means doing + * it here instead of accepting "ethereum-mainnet-beta". + */ +function resolveChain(chain: string, network?: string): { ops: ChainOps; network: string } { + const ops = CHAINS[chain]; + if (!ops) { + throw httpErrors.badRequest(`Unsupported chain: ${chain}. Supported: ${SUPPORTED_CHAINS.join(', ')}`); + } + + const resolved = network || ops.defaultNetwork(); + if (!ops.networks.includes(resolved)) { + throw httpErrors.badRequest( + `Network '${resolved}' is not a ${chain} network. Available: ${ops.networks.join(', ')}`, + ); + } + // Surfaces a misconfigured-but-listed network as a clear error rather than a + // downstream failure inside the chain client. + if (chain === 'solana') getSolanaNetworkConfig(resolved); + else getEthereumNetworkConfig(resolved); + + return { ops, network: resolved }; +} + +interface ChainParams { + chain: string; +} + +export const chainRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(sensible); + + fastify.get<{ Params: ChainParams; Querystring: StatusRequestType }>( + '/:chain/status', + { + schema: { + description: 'Get the status of a chain and network', + tags: ['/chains'], + params: ChainParamsSchema, + querystring: StatusRequestSchema, + response: { 200: StatusResponseSchema }, + }, + }, + async (request) => { + const { ops, network } = resolveChain(request.params.chain, request.query.network); + return ops.status(fastify, network); + }, + ); + + fastify.get<{ Params: ChainParams; Querystring: EstimateGasRequestType }>( + '/:chain/estimate-gas', + { + schema: { + description: 'Estimate the current transaction fee on a chain', + tags: ['/chains'], + params: ChainParamsSchema, + querystring: EstimateGasRequestSchema, + response: { 200: EstimateGasResponseSchema }, + }, + }, + async (request) => { + const { ops, network } = resolveChain(request.params.chain, request.query.network); + return ops.estimateGas(fastify, network); + }, + ); + + fastify.post<{ Params: ChainParams; Body: BalanceRequestType }>( + '/:chain/balances', + { + schema: { + description: 'Get token balances for a wallet', + tags: ['/chains'], + params: ChainParamsSchema, + body: BalanceRequestSchema, + response: { 200: BalanceResponseSchema }, + }, + }, + async (request) => { + const { chain } = request.params; + const { ops, network } = resolveChain(chain, request.body.network); + const address = request.body.address || defaultWalletFor(chain); + if (!address) { + throw httpErrors.badRequest(`No address given and no default wallet configured for ${chain}`); + } + return ops.balances(fastify, network, address, request.body.tokens); + }, + ); + + fastify.post<{ Params: ChainParams; Body: PollRequestType }>( + '/:chain/poll', + { + schema: { + description: 'Poll a transaction by signature/hash', + tags: ['/chains'], + params: ChainParamsSchema, + body: PollRequestSchema, + response: { 200: PollResponseSchema }, + }, + }, + async (request) => { + const { ops, network } = resolveChain(request.params.chain, request.body.network); + return ops.poll(fastify, network, request.body.signature); + }, + ); + + fastify.post<{ Params: ChainParams; Body: WrapRequestType }>( + '/:chain/wrap', + { + schema: { + description: 'Wrap native token into its wrapped form (SOL to WSOL, ETH to WETH, ...)', + tags: ['/chains'], + params: ChainParamsSchema, + body: WrapRequestSchema, + response: { 200: WrapResponseSchema }, + }, + }, + async (request) => { + const { ops, network } = resolveChain(request.params.chain, request.body.network); + return ops.wrap(fastify, network, request.body.address, request.body.amount); + }, + ); + + fastify.post<{ Params: ChainParams; Body: UnwrapRequestType }>( + '/:chain/unwrap', + { + schema: { + description: 'Unwrap a wrapped native token back into the native token', + tags: ['/chains'], + params: ChainParamsSchema, + body: UnwrapRequestSchema, + response: { 200: WrapResponseSchema }, + }, + }, + async (request) => { + const { chain } = request.params; + const { ops, network } = resolveChain(chain, request.body.network); + if (ops.unwrapRequiresAmount && !request.body.amount) { + throw httpErrors.badRequest(`amount is required to unwrap on ${chain}`); + } + return ops.unwrap(fastify, network, request.body.address, request.body.amount); + }, + ); + + // EVM-only operations keep their chain-specific paths. + fastify.register( + async (evm) => { + await evm.register(sensible); + evm.register(allowancesRoute); + evm.register(approveRoute); + }, + { prefix: '/ethereum' }, + ); +}; + +function defaultWalletFor(chain: string): string | undefined { + return chain === 'solana' ? getSolanaChainConfig().defaultWallet : getEthereumChainConfig().defaultWallet; +} + +export default chainRoutes; diff --git a/src/chains/ethereum/ethereum.routes.ts b/src/chains/ethereum/ethereum.routes.ts deleted file mode 100644 index e3902ba968..0000000000 --- a/src/chains/ethereum/ethereum.routes.ts +++ /dev/null @@ -1,36 +0,0 @@ -import sensible from '@fastify/sensible'; -import { FastifyPluginAsync } from 'fastify'; - -import { allowancesRoute } from './routes/allowances'; -import { approveRoute } from './routes/approve'; -import { balancesRoute } from './routes/balances'; -import { estimateGasRoute } from './routes/estimate-gas'; -import { pollRoute } from './routes/poll'; -import { statusRoute } from './routes/status'; -import { unwrapRoute } from './routes/unwrap'; -import { wrapRoute } from './routes/wrap'; - -// Register the type declaration needed for Fastify schema tags -declare module 'fastify' { - interface FastifySchema { - tags?: readonly string[]; - description?: string; - } -} - -export const ethereumRoutes: FastifyPluginAsync = async (fastify) => { - // Register @fastify/sensible plugin to enable httpErrors - await fastify.register(sensible); - - // Register all the route handlers - fastify.register(statusRoute); - fastify.register(estimateGasRoute); - fastify.register(balancesRoute); - fastify.register(pollRoute); - fastify.register(allowancesRoute); - fastify.register(approveRoute); - fastify.register(wrapRoute); - fastify.register(unwrapRoute); -}; - -export default ethereumRoutes; diff --git a/src/chains/ethereum/ethereum.ts b/src/chains/ethereum/ethereum.ts index ec3e34a931..cb4eb4bdcc 100644 --- a/src/chains/ethereum/ethereum.ts +++ b/src/chains/ethereum/ethereum.ts @@ -10,6 +10,7 @@ import { RPCProvider } from '../../rpc/rpc-provider-base'; import { TokenValue, tokenValueToString } from '../../services/base'; import { ConfigManagerCertPassphrase } from '../../services/config-manager-cert-passphrase'; import { ConfigManagerV2 } from '../../services/config-manager-v2'; +import { transactionFailed } from '../../services/error-handler'; import { logger, redactUrl } from '../../services/logger'; import { TokenService } from '../../services/token-service'; import { walletPath, isHardwareWallet as checkIsHardwareWallet } from '../../wallet/utils'; @@ -29,6 +30,15 @@ export interface TokenInfo { export type NewBlockHandler = (bn: number) => void; export type NewDebugMsgHandler = (msg: any) => void; +/** + * Outcome of an EVM liquidity/pool transaction as a route is allowed to report it. + * A revert is not one of the cases: it throws, so it can never be mistaken for PENDING. + * See {@link Ethereum.handleTransactionConfirmation}. + */ +export type EthereumTransactionOutcome = + | { confirmed: false; signature: string } + | { confirmed: true; signature: string; receipt: providers.TransactionReceipt; fee: number }; + // Networks that support EIP-1559 (type 2) transactions export const EIP1559_NETWORKS = [ 'mainnet', @@ -47,6 +57,8 @@ export class Ethereum { public network: string; public nativeTokenSymbol: string; public chainId: number; + /** Tokens read from the chain because the configured list did not have them. */ + private readonly chainReadTokens = new Map(); public rpcUrl: string; public swapProvider: string; public gasPrice?: number | null; @@ -527,9 +539,19 @@ export class Ethereum { } /** - * Get token info by symbol or address from local token list only + * Get token info by symbol or address, from the configured list or the chain. + * + * A symbol can only come from the list — there is nothing to ask the chain about a + * name it does not hold — so an unknown one is still undefined. An address is + * different: it identifies a contract that can be asked what it is, and until it was, + * every route describing a pool by its token addresses failed on any token the list + * happened to omit. Uniswap's pool-info answered `Token information not found for + * pool` for a real pool on real tokens, which is what Solana's getToken has always + * avoided by reading the mint. + * * @param tokenSymbol Token symbol or contract address - * @returns TokenInfo object or undefined if token not found in local list + * @returns TokenInfo, or undefined for a symbol that is not listed and an address + * that is not an ERC-20 */ public async getToken(tokenSymbol: string): Promise { const tokenList = await this.getTokenList(); @@ -544,17 +566,82 @@ export class Ethereum { } // If not found by symbol, check if it's a valid address + let normalizedAddress: string; try { - const normalizedAddress = utils.getAddress(tokenSymbol); - // Try to find token by normalized address - return tokenList.find( - (token: TokenInfo) => - token.address.toLowerCase() === normalizedAddress.toLowerCase() && token.chainId === this.chainId, - ); + normalizedAddress = utils.getAddress(tokenSymbol); } catch { - // If not a valid address format, return undefined + // Not an address, so the list was the only place it could have been. + return undefined; + } + + const tokenByAddress = tokenList.find( + (token: TokenInfo) => + token.address.toLowerCase() === normalizedAddress.toLowerCase() && token.chainId === this.chainId, + ); + + return tokenByAddress ?? (await this.tokenFromChainCached(normalizedAddress)); + } + + /** + * A chain-read token, remembered for the life of the process. + * + * getToken runs in loops — over the tokens of a balance request, over every position a + * wallet owns — so an address the list omits would otherwise cost three eth_calls on + * every pass, forever, for a name, symbol and decimals that cannot change. Only + * successes are remembered: an address with no contract today may have one tomorrow. + */ + private async tokenFromChainCached(address: string): Promise { + const cached = this.chainReadTokens.get(address); + if (cached) { + return cached; + } + + const token = await this.fetchTokenFromChain(address); + if (!token) { return undefined; } + + this.chainReadTokens.set(address, token); + return token; + } + + /** + * Read a token's name, symbol and decimals from the chain. + * + * getToken above only searches the configured list, so an unlisted token is simply + * absent there. These three are standard ERC-20 view calls and the ABI for them is + * already in getContract, so nothing outside the chain is consulted. + * + * Returns null when the address is not a contract that answers them — a wallet + * address, or a token predating the metadata methods — rather than inventing a name. + */ + public async fetchTokenFromChain(address: string): Promise { + let normalizedAddress: string; + try { + normalizedAddress = getAddress(address); + } catch { + return null; + } + + try { + const contract = this.getContract(normalizedAddress); + const [name, symbol, decimals] = await Promise.all([contract.name(), contract.symbol(), contract.decimals()]); + + if (!symbol) { + return null; + } + + return { + address: normalizedAddress, + chainId: this.chainId, + decimals: Number(decimals), + name: name || symbol, + symbol, + }; + } catch (e: any) { + logger.debug(`No ERC-20 metadata at ${normalizedAddress}: ${e.message}`); + return null; + } } /** @@ -1026,6 +1113,60 @@ export class Ethereum { return null; } + /** + * Single confirmation gate for EVM liquidity and pool transactions (open/close position, + * add/remove liquidity, collect fees, create pool, execute swap). + * + * `handleTransactionExecution` has three outcomes but only two of them are a valid response + * body, so every caller used to have to remember two separate checks — and almost none did: + * + * - no receipt (still pending after the extended poll) — dereferencing it throws a TypeError + * that the route catch turns into a generic 500, losing the transaction hash and with it + * any chance of reconciling a transaction that lands a minute later. + * - `receipt.status === 0` (reverted on-chain) — forwarding it verbatim as the response + * `status` reports the revert as {@link TransactionStatus.PENDING}, which is also 0, so a + * poller waits on it forever while the route's pre-send amounts are booked as if the + * tokens had moved. + * + * This helper resolves both: + * + * - still pending -> `{ confirmed: false, signature: tx.hash }`. The caller returns + * `{ signature, status: TransactionStatus.PENDING }` with NO `data` — the amounts it + * computed before sending have not moved and must not be reported as if they had. + * - reverted -> throws the shared 400 TRANSACTION_FAILED, the same terminal, non-retryable + * error the Solana routes throw for a landed-but-failed transaction. + * - confirmed -> `{ confirmed: true, signature, receipt, fee }`, fee already converted from + * gas units to the chain's native currency. + */ + public async handleTransactionConfirmation(tx: TransactionResponse): Promise { + const receipt = await this.handleTransactionExecution(tx); + + if (!receipt) { + logger.warn(`Transaction ${tx.hash} still pending — reporting PENDING so the caller can reconcile it later`); + return { confirmed: false, signature: tx.hash }; + } + + if (receipt.status === 0) { + throw transactionFailed( + `Transaction ${receipt.transactionHash} reverted on-chain. Gas was spent; no tokens moved.`, + ); + } + + if (receipt.status !== 1) { + // No status on the receipt (pre-Byzantium chain or a provider quirk): neither a + // confirmation nor a revert, so report it as pending rather than guessing. + logger.warn(`Transaction ${receipt.transactionHash} has no receipt status — reporting PENDING`); + return { confirmed: false, signature: receipt.transactionHash }; + } + + return { + confirmed: true, + signature: receipt.transactionHash, + receipt, + fee: parseFloat(utils.formatUnits(receipt.gasUsed.mul(receipt.effectiveGasPrice), 18)), + }; + } + /** * Handle transaction confirmation status and return appropriate response * Similar to Solana's handleConfirmation helper @@ -1045,6 +1186,7 @@ export class Ethereum { expectedAmountOut: number, side?: 'BUY' | 'SELL', txHash?: string, // Optional tx hash for pending transactions + slippagePct?: number, // Slippage tolerance actually applied to the swap (echoed in data) ): { signature: string; status: number; @@ -1056,6 +1198,7 @@ export class Ethereum { fee: number; baseTokenBalanceChange: number; quoteTokenBalanceChange: number; + slippagePct?: number; }; } { if (!txReceipt) { @@ -1118,6 +1261,7 @@ export class Ethereum { fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } diff --git a/src/chains/ethereum/routes/allowances.ts b/src/chains/ethereum/routes/allowances.ts index 6bad1e72a1..8c0cefe6ed 100644 --- a/src/chains/ethereum/routes/allowances.ts +++ b/src/chains/ethereum/routes/allowances.ts @@ -134,7 +134,7 @@ export async function getEthereumAllowances( ); // Then check Permit2's allowance to Universal Router - const [amount, expiration, nonce] = await permit2Contract.allowance( + const [amount, expiration] = await permit2Contract.allowance( address, tokenInfoMap[symbol].address, universalRouterAddress, @@ -217,7 +217,7 @@ export const allowancesRoute: FastifyPluginAsync = async (fastify) => { { schema: { description: 'Get token allowances', - tags: ['/chain/ethereum'], + tags: ['/chains'], body: AllowancesRequestSchema, response: { 200: AllowancesResponseSchema, diff --git a/src/chains/ethereum/routes/approve.ts b/src/chains/ethereum/routes/approve.ts index 026b6be472..215db40b30 100644 --- a/src/chains/ethereum/routes/approve.ts +++ b/src/chains/ethereum/routes/approve.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync, FastifyInstance } from 'fastify'; import { getSpender as pancakeswapSpender } from '../../../connectors/pancakeswap/pancakeswap.contracts'; import { getSpender as uniswapSpender } from '../../../connectors/uniswap/uniswap.contracts'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { bigNumberWithDecimalToStr } from '../../../services/base'; import { logger } from '../../../services/logger'; import { Ethereum } from '../ethereum'; @@ -178,17 +179,20 @@ export async function approveEthereumToken( const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with timeout - const receipt = await ethereum.handleTransactionExecution(txResponse); + // A revert throws 400 TRANSACTION_FAILED out of the helper; only a still-pending + // transaction comes back unconfirmed, and an approval cannot be reported without a + // confirmed allowance. + const outcome = await ethereum.handleTransactionConfirmation(txResponse); - if (!receipt || receipt.status === -1) { + if (!outcome.confirmed) { throw new Error('Transaction timed out or failed to get receipt'); } approval = { - hash: receipt.transactionHash, + hash: outcome.signature, nonce: nonce, - gasUsed: receipt.gasUsed, - effectiveGasPrice: receipt.effectiveGasPrice, + gasUsed: outcome.receipt.gasUsed, + effectiveGasPrice: outcome.receipt.effectiveGasPrice, }; } else { // Regular wallet flow @@ -207,18 +211,17 @@ export async function approveEthereumToken( const tx = await ethereum.approveERC20(contract, wallet, spenderAddress, amountBigNumber); // Wait for the transaction to be mined with timeout (60 seconds for approvals) - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); - if (!receipt || receipt.status === -1) { + if (!outcome.confirmed) { throw new Error('Transaction timed out or failed to get receipt'); } approval = { hash: tx.hash, nonce: tx.nonce, - gasUsed: receipt.gasUsed, - effectiveGasPrice: receipt.effectiveGasPrice, - status: receipt.status, + gasUsed: outcome.receipt.gasUsed, + effectiveGasPrice: outcome.receipt.effectiveGasPrice, }; } } else { @@ -229,7 +232,6 @@ export async function approveEthereumToken( nonce: 0, gasUsed: ethers.BigNumber.from('0'), effectiveGasPrice: ethers.BigNumber.from('0'), - status: 1, }; } @@ -291,20 +293,18 @@ export async function approveEthereumToken( const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with extended timeout - const permit2Receipt = await ethereum.handleTransactionExecution(txResponse); + const permit2Outcome = await ethereum.handleTransactionConfirmation(txResponse); - if (!permit2Receipt) { + if (!permit2Outcome.confirmed) { throw new Error('Permit2 transaction timed out or failed to get receipt'); } - logger.info(`Permit2 approval transaction confirmed: ${permit2Receipt.transactionHash}`); + logger.info(`Permit2 approval transaction confirmed: ${permit2Outcome.signature}`); // Update fee to include both transactions - if (permit2Receipt.gasUsed && permit2Receipt.effectiveGasPrice) { - const permit2FeeInWei = permit2Receipt.gasUsed.mul(permit2Receipt.effectiveGasPrice); - const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); - feeInEth = utils.formatEther(totalFeeInWei); - } + const permit2FeeInWei = permit2Outcome.receipt.gasUsed.mul(permit2Outcome.receipt.effectiveGasPrice); + const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); + feeInEth = utils.formatEther(totalFeeInWei); } else { // Regular wallet flow for Permit2 approve const wallet = await ethereum.getWallet(address); @@ -326,20 +326,18 @@ export async function approveEthereumToken( ); // Wait for confirmation with extended timeout - const permit2Receipt = await ethereum.handleTransactionExecution(permit2Tx); + const permit2Outcome = await ethereum.handleTransactionConfirmation(permit2Tx); - if (!permit2Receipt || permit2Receipt.status === -1) { + if (!permit2Outcome.confirmed) { throw new Error('Permit2 transaction timed out or failed to get receipt'); } - logger.info(`Permit2 approval transaction confirmed: ${permit2Receipt.transactionHash}`); + logger.info(`Permit2 approval transaction confirmed: ${permit2Outcome.signature}`); // Update fee to include both transactions - if (permit2Receipt.gasUsed && permit2Receipt.effectiveGasPrice) { - const permit2FeeInWei = permit2Receipt.gasUsed.mul(permit2Receipt.effectiveGasPrice); - const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); - feeInEth = utils.formatEther(totalFeeInWei); - } + const permit2FeeInWei = permit2Outcome.receipt.gasUsed.mul(permit2Outcome.receipt.effectiveGasPrice); + const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); + feeInEth = utils.formatEther(totalFeeInWei); } logger.info( @@ -349,7 +347,10 @@ export async function approveEthereumToken( return { signature: approval.hash, - status: approval.status ?? -1, + // Every path that reaches here confirmed: the helper throws on a revert and the + // branches above bail out while a transaction is still pending. `approval.status ?? -1` + // used to report a confirmed Ledger approval (which never carried a status) as FAILED. + status: TransactionStatus.CONFIRMED, data: { tokenAddress: fullToken.address, spender: isUniversalRouter ? universalRouterAddress || spenderAddress : spenderAddress, @@ -387,7 +388,7 @@ export const approveRoute: FastifyPluginAsync = async (fastify) => { { schema: { description: 'Approve token spending', - tags: ['/chain/ethereum'], + tags: ['/chains'], body: ApproveRequestSchema, response: { 200: ApproveResponseSchema, diff --git a/src/chains/ethereum/routes/balances.ts b/src/chains/ethereum/routes/balances.ts index 45ac9d426c..5330e03f0d 100644 --- a/src/chains/ethereum/routes/balances.ts +++ b/src/chains/ethereum/routes/balances.ts @@ -1,9 +1,8 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { BalanceRequestType, BalanceResponseType, BalanceResponseSchema } from '../../../schemas/chain-schema'; +import { BalanceResponseType } from '../../../schemas/chain-schema'; import { logger } from '../../../services/logger'; import { Ethereum } from '../ethereum'; -import { EthereumBalanceRequest } from '../schemas'; export async function getEthereumBalances( fastify: FastifyInstance, @@ -23,29 +22,3 @@ export async function getEthereumBalances( throw fastify.httpErrors.internalServerError(`Failed to get balances: ${error.message}`); } } - -export const balancesRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: BalanceRequestType; - Reply: BalanceResponseType; - }>( - '/balances', - { - schema: { - description: - 'Get Ethereum balances. If no tokens specified or empty array provided, returns native token (ETH) and only non-zero balances for tokens from the token list. If specific tokens are requested, returns those exact tokens with their balances, including zeros.', - tags: ['/chain/ethereum'], - body: EthereumBalanceRequest, - response: { - 200: BalanceResponseSchema, - }, - }, - }, - async (request) => { - const { network, address, tokens } = request.body; - return await getEthereumBalances(fastify, network, address, tokens); - }, - ); -}; - -export default balancesRoute; diff --git a/src/chains/ethereum/routes/estimate-gas.ts b/src/chains/ethereum/routes/estimate-gas.ts index 40541f560a..dc34456ba2 100644 --- a/src/chains/ethereum/routes/estimate-gas.ts +++ b/src/chains/ethereum/routes/estimate-gas.ts @@ -1,9 +1,8 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { EstimateGasRequestType, EstimateGasResponse, EstimateGasResponseSchema } from '../../../schemas/chain-schema'; +import { EstimateGasResponse } from '../../../schemas/chain-schema'; import { logger } from '../../../services/logger'; import { Ethereum, EIP1559_NETWORKS } from '../ethereum'; -import { EthereumEstimateGasRequest } from '../schemas'; export async function estimateGasEthereum(fastify: FastifyInstance, network: string): Promise { try { @@ -69,28 +68,3 @@ export async function estimateGasEthereum(fastify: FastifyInstance, network: str throw fastify.httpErrors.internalServerError(`Failed to estimate gas for network ${network}: ${error.message}`); } } - -export const estimateGasRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: EstimateGasRequestType; - Reply: EstimateGasResponse; - }>( - '/estimate-gas', - { - schema: { - description: 'Estimate gas prices for Ethereum transactions', - tags: ['/chain/ethereum'], - querystring: EthereumEstimateGasRequest, - response: { - 200: EstimateGasResponseSchema, - }, - }, - }, - async (request) => { - const { network } = request.query; - return await estimateGasEthereum(fastify, network); - }, - ); -}; - -export default estimateGasRoute; diff --git a/src/chains/ethereum/routes/poll.ts b/src/chains/ethereum/routes/poll.ts index 3d90df9e06..34c616e768 100644 --- a/src/chains/ethereum/routes/poll.ts +++ b/src/chains/ethereum/routes/poll.ts @@ -1,11 +1,10 @@ import { ethers } from 'ethers'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { PollRequestType, PollResponseType, PollResponseSchema } from '../../../schemas/chain-schema'; +import { PollResponseType, TransactionStatusCode } from '../../../schemas/chain-schema'; import { getConnector } from '../../../services/connection-manager'; import { logger } from '../../../services/logger'; import { Ethereum } from '../ethereum'; -import { EthereumPollRequest } from '../schemas'; // Helper function for transaction response formatting @@ -36,57 +35,25 @@ export async function pollEthereumTransaction( const ethereum = await Ethereum.getInstance(network); const currentBlock = await ethereum.getCurrentBlockNumber(); - let txData = await ethereum.getTransaction(signature); + const txData = await ethereum.getTransaction(signature); let txBlock, txReceipt, txStatus; if (!txData) { - const MAX_RETRIES = 3; - const RETRY_DELAY_MS = 1000; - let retryCount = 0; - - while (retryCount < MAX_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - txData = await ethereum.getTransaction(signature); - if (txData) break; - retryCount++; - } - - if (!txData) { - // tx not found after retries - logger.info(`Transaction ${signature} not found in mempool or does not exist after ${MAX_RETRIES} retries.`); - txBlock = -1; - txReceipt = null; - txStatus = -1; - } - } - - if (txData) { + // Unknown to the node: never received or dropped. eth_getTransactionByHash + // returns mempool transactions, so not-found is distinct from pending. + logger.info(`Transaction ${signature} not found in mempool or on-chain.`); + txBlock = -1; + txReceipt = null; + txStatus = TransactionStatusCode.NOT_FOUND; + } else { txReceipt = await ethereum.getTransactionReceipt(signature); if (txReceipt === null) { - // tx is in the mempool + // In the mempool, awaiting inclusion txBlock = -1; - txReceipt = null; - - // In stateless approach, we simply check if the transaction is still pending - // We use a basic status code of 0 for pending transactions in mempool - txStatus = 0; - - // Check if transaction is likely to be processed based on gas price - if (txData.gasPrice) { - const currentGasPrice = await ethereum.estimateGasPrice(); - // Convert current gas price from GWEI to wei for comparison - const currentGasPriceWei = currentGasPrice * 1e9; - // If the transaction's gas price is significantly lower than current gas price, - // it might be stuck (status 3), otherwise it's likely to be processed (status 2) - if (txData.gasPrice.toNumber() < currentGasPriceWei * 0.8) { - txStatus = 3; // Likely stuck - } else { - txStatus = 2; // Likely to be processed - } - } + txStatus = TransactionStatusCode.PENDING; } else { - // tx has been processed txBlock = txReceipt.blockNumber; - txStatus = typeof txReceipt.status === 'number' ? 1 : -1; + // Receipt status 0 = reverted, 1 = success (undefined only pre-Byzantium) + txStatus = txReceipt.status === 0 ? TransactionStatusCode.FAILED : TransactionStatusCode.CONFIRMED; // decode logs if (connector) { @@ -121,28 +88,3 @@ export async function pollEthereumTransaction( throw fastify.httpErrors.internalServerError(`Failed to poll transaction: ${error.message}`); } } - -export const pollRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: PollRequestType; - Reply: PollResponseType; - }>( - '/poll', - { - schema: { - description: 'Poll Ethereum transaction status', - tags: ['/chain/ethereum'], - body: EthereumPollRequest, - response: { - 200: PollResponseSchema, - }, - }, - }, - async (request) => { - const { network, signature } = request.body; - return await pollEthereumTransaction(fastify, network, signature); - }, - ); -}; - -export default pollRoute; diff --git a/src/chains/ethereum/routes/status.ts b/src/chains/ethereum/routes/status.ts index b2714d6afe..35f56615d5 100644 --- a/src/chains/ethereum/routes/status.ts +++ b/src/chains/ethereum/routes/status.ts @@ -1,10 +1,7 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { StatusRequestType, StatusResponseType, StatusResponseSchema } from '../../../schemas/chain-schema'; +import { StatusResponseType } from '../../../schemas/chain-schema'; import { logger } from '../../../services/logger'; import { Ethereum } from '../ethereum'; import { getEthereumChainConfig } from '../ethereum.config'; -import { EthereumStatusRequest } from '../schemas'; export async function getEthereumStatus(network: string): Promise { try { @@ -57,45 +54,3 @@ export async function getEthereumStatus(network: string): Promise { - fastify.get<{ - Querystring: StatusRequestType; - Reply: StatusResponseType; - }>( - '/status', - { - schema: { - description: 'Get Ethereum chain status', - tags: ['/chain/ethereum'], - querystring: EthereumStatusRequest, - response: { - 200: StatusResponseSchema, - }, - }, - }, - async (request, reply) => { - const { network } = request.query; - try { - // This will handle node timeout internally - return await getEthereumStatus(network); - } catch (error) { - // This will catch any other unexpected errors - logger.error(`Error in Ethereum status endpoint: ${error.message}`); - reply.status(500); - // Return a minimal valid response - return { - chain: 'ethereum', - network, - rpcUrl: 'unavailable', - rpcProvider: 'unavailable', - currentBlockNumber: 0, - nativeCurrency: 'ETH', - swapProvider: '', - }; - } - }, - ); -}; - -export default statusRoute; diff --git a/src/chains/ethereum/routes/unwrap.ts b/src/chains/ethereum/routes/unwrap.ts index 54001c7a03..db13323ac2 100644 --- a/src/chains/ethereum/routes/unwrap.ts +++ b/src/chains/ethereum/routes/unwrap.ts @@ -1,11 +1,11 @@ import { ethers, utils } from 'ethers'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { bigNumberWithDecimalToStr } from '../../../services/base'; import { logger } from '../../../services/logger'; -import { Ethereum } from '../ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../ethereum'; import { EthereumLedger } from '../ethereum-ledger'; -import { UnwrapRequestSchema, UnwrapResponseSchema, UnwrapRequestType, UnwrapResponseType } from '../schemas'; // Default gas limit for unwrap operations const UNWRAP_GAS_LIMIT = 50000; @@ -72,9 +72,9 @@ export async function unwrapEthereum(fastify: FastifyInstance, network: string, const amountInWei = utils.parseEther(amount); try { - let transaction; + let transaction: ethers.providers.TransactionResponse; let nonce: number; - let receipt; + let outcome: EthereumTransactionOutcome; if (isHardware) { // Hardware wallet flow @@ -117,12 +117,7 @@ export async function unwrapEthereum(fastify: FastifyInstance, network: string, const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); - - transaction = { - hash: receipt.transactionHash, - nonce: nonce, - }; + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet: ethers.Wallet; @@ -157,19 +152,21 @@ export async function unwrapEthereum(fastify: FastifyInstance, network: string, nonce = transaction.nonce; // Wait for transaction confirmation with timeout - receipt = await ethereum.handleTransactionExecution(transaction); + outcome = await ethereum.handleTransactionConfirmation(transaction); } - // Calculate actual fee from receipt - let feeInEth = '0'; - if (receipt.gasUsed && receipt.effectiveGasPrice) { - const feeInWei = receipt.gasUsed.mul(receipt.effectiveGasPrice); - feeInEth = utils.formatEther(feeInWei); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED — it is never + // reported as PENDING (receipt.status 0 and TransactionStatus.PENDING are the same number). + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } + // Calculate actual fee from the confirmed receipt + const feeInEth = utils.formatEther(outcome.receipt.gasUsed.mul(outcome.receipt.effectiveGasPrice)); + return { - signature: transaction.hash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { nonce: nonce, fee: feeInEth, @@ -206,29 +203,3 @@ export async function unwrapEthereum(fastify: FastifyInstance, network: string, ); } } - -export const unwrapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: UnwrapRequestType; - Reply: UnwrapResponseType; - }>( - '/unwrap', - { - schema: { - description: 'Unwrap wrapped token to native token (e.g., WETH to ETH, WBNB to BNB)', - tags: ['/chain/ethereum'], - body: UnwrapRequestSchema, - response: { - 200: UnwrapResponseSchema, - }, - }, - }, - async (request) => { - const { network, address, amount } = request.body; - - return await unwrapEthereum(fastify, network, address, amount); - }, - ); -}; - -export default unwrapRoute; diff --git a/src/chains/ethereum/routes/wrap.ts b/src/chains/ethereum/routes/wrap.ts index b726f9cd6f..08a4d4adf6 100644 --- a/src/chains/ethereum/routes/wrap.ts +++ b/src/chains/ethereum/routes/wrap.ts @@ -1,11 +1,11 @@ import { ethers, utils } from 'ethers'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { bigNumberWithDecimalToStr } from '../../../services/base'; import { logger } from '../../../services/logger'; -import { Ethereum } from '../ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../ethereum'; import { EthereumLedger } from '../ethereum-ledger'; -import { WrapRequestSchema, WrapResponseSchema, WrapRequestType, WrapResponseType } from '../schemas'; // Gas limit for wrap operations. Plain WETH9 deposit() costs ~27k, but networks // fronting WETH with a proxy (e.g. robinhoodchain) need appreciably more. @@ -73,9 +73,9 @@ export async function wrapEthereum(fastify: FastifyInstance, network: string, ad const amountInWei = utils.parseEther(amount); try { - let transaction; + let transaction: ethers.providers.TransactionResponse; let nonce: number; - let receipt; + let outcome: EthereumTransactionOutcome; if (isHardware) { // Hardware wallet flow @@ -110,12 +110,7 @@ export async function wrapEthereum(fastify: FastifyInstance, network: string, ad const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); - - transaction = { - hash: receipt.transactionHash, - nonce: nonce, - }; + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet: ethers.Wallet; @@ -143,19 +138,21 @@ export async function wrapEthereum(fastify: FastifyInstance, network: string, ad nonce = transaction.nonce; // Wait for transaction confirmation with timeout - receipt = await ethereum.handleTransactionExecution(transaction); + outcome = await ethereum.handleTransactionConfirmation(transaction); } - // Calculate actual fee from receipt - let feeInEth = '0'; - if (receipt.gasUsed && receipt.effectiveGasPrice) { - const feeInWei = receipt.gasUsed.mul(receipt.effectiveGasPrice); - feeInEth = utils.formatEther(feeInWei); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED — it is never + // reported as PENDING (receipt.status 0 and TransactionStatus.PENDING are the same number). + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } + // Calculate actual fee from the confirmed receipt + const feeInEth = utils.formatEther(outcome.receipt.gasUsed.mul(outcome.receipt.effectiveGasPrice)); + return { - signature: transaction.hash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { nonce: nonce, fee: feeInEth, @@ -190,29 +187,3 @@ export async function wrapEthereum(fastify: FastifyInstance, network: string, ad ); } } - -export const wrapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: WrapRequestType; - Reply: WrapResponseType; - }>( - '/wrap', - { - schema: { - description: 'Wrap native token to wrapped token (e.g., ETH to WETH, BNB to WBNB)', - tags: ['/chain/ethereum'], - body: WrapRequestSchema, - response: { - 200: WrapResponseSchema, - }, - }, - }, - async (request) => { - const { network, address, amount } = request.body; - - return await wrapEthereum(fastify, network, address, amount); - }, - ); -}; - -export default wrapRoute; diff --git a/src/chains/ethereum/schemas.ts b/src/chains/ethereum/schemas.ts index c7cae2d3ce..613f750f5d 100644 --- a/src/chains/ethereum/schemas.ts +++ b/src/chains/ethereum/schemas.ts @@ -62,61 +62,79 @@ export const EthereumPollRequest = Type.Object({ }); // Allowances request schema (multiple tokens) -export const AllowancesRequestSchema = Type.Object({ - network: EthereumNetworkParameter, - address: EthereumAddressParameter, - spender: Type.String({ - description: 'Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address', - examples: [EXAMPLE_SPENDER], - }), - tokens: Type.Array(Type.String(), { - description: 'Array of token symbols or addresses', - examples: [EXAMPLE_ALLOWANCE_TOKENS], - }), -}); +export const AllowancesRequestSchema = Type.Object( + { + network: EthereumNetworkParameter, + address: EthereumAddressParameter, + spender: Type.String({ + description: 'Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address', + examples: [EXAMPLE_SPENDER], + }), + tokens: Type.Array(Type.String(), { + description: 'Array of token symbols or addresses', + examples: [EXAMPLE_ALLOWANCE_TOKENS], + }), + }, + { $id: 'AllowancesRequest', additionalProperties: false }, +); // Allowances response schema -export const AllowancesResponseSchema = Type.Object({ - spender: Type.String(), - approvals: Type.Record(Type.String(), Type.String()), -}); +export const AllowancesResponseSchema = Type.Object( + { + spender: Type.String(), + approvals: Type.Record(Type.String(), Type.String()), + }, + // The last two chain responses without a name of their own. Every other route on + // /chains publishes a component; these were inlined, so a generated client got an + // anonymous model for them and nothing to import. + { $id: 'AllowancesResponse' }, +); // Approve request schema -export const ApproveRequestSchema = Type.Object({ - network: EthereumNetworkParameter, - address: EthereumAddressParameter, - spender: Type.String({ - description: 'Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address', - examples: [EXAMPLE_SPENDER], - }), - token: Type.String({ - description: 'Token symbol or address', - examples: [EXAMPLE_ALLOWANCE_TOKENS[0]], - }), - amount: Type.Optional( - Type.String({ - description: 'The amount to approve. If not provided, defaults to maximum amount (unlimited approval).', - default: '', +export const ApproveRequestSchema = Type.Object( + { + network: EthereumNetworkParameter, + address: EthereumAddressParameter, + spender: Type.String({ + description: 'Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address', + examples: [EXAMPLE_SPENDER], }), - ), -}); + token: Type.String({ + description: 'Token symbol or address', + examples: [EXAMPLE_ALLOWANCE_TOKENS[0]], + }), + amount: Type.Optional( + Type.String({ + description: 'The amount to approve. If not provided, defaults to maximum amount (unlimited approval).', + default: '', + }), + ), + }, + { $id: 'ApproveRequest', additionalProperties: false }, +); // Approve response schema -export const ApproveResponseSchema = Type.Object({ - signature: Type.String(), - status: Type.Number({ description: 'TransactionStatus enum value' }), +export const ApproveResponseSchema = Type.Object( + { + signature: Type.String(), + status: Type.Number({ description: 'TransactionStatus enum value' }), - // Only included when status = CONFIRMED - data: Type.Optional( - Type.Object({ - tokenAddress: Type.String(), - spender: Type.String(), - amount: Type.String(), - nonce: Type.Number(), - fee: Type.String(), - }), - ), -}); + // Only included when status = CONFIRMED + data: Type.Optional( + Type.Object( + { + tokenAddress: Type.String(), + spender: Type.String(), + amount: Type.String(), + nonce: Type.Number(), + fee: Type.String(), + }, + { $id: 'ApproveResponseData' }, + ), + ), + }, + { $id: 'ApproveResponse' }, +); // Wrap request schema export const WrapRequestSchema = Type.Object({ diff --git a/src/chains/solana/routes/balances.ts b/src/chains/solana/routes/balances.ts index bb58caafc0..1c4c36045a 100644 --- a/src/chains/solana/routes/balances.ts +++ b/src/chains/solana/routes/balances.ts @@ -1,8 +1,7 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { BalanceRequestType, BalanceResponseType, BalanceResponseSchema } from '../../../schemas/chain-schema'; +import { BalanceResponseType } from '../../../schemas/chain-schema'; import { logger } from '../../../services/logger'; -import { SolanaBalanceRequest } from '../schemas'; import { Solana } from '../solana'; /** @@ -30,41 +29,3 @@ export async function getSolanaBalances( throw fastify.httpErrors.internalServerError(`Failed to get balances: ${error.message}`); } } - -export const balancesRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: BalanceRequestType; - Reply: BalanceResponseType; - }>( - '/balances', - { - schema: { - description: - "Get token balances for a Solana address. Only returns tokens in the network's token list. If no tokens specified or empty array provided, returns non-zero balances for tokens from the token list that are found in the wallet (includes SOL even if zero). If specific tokens are requested, returns those exact tokens with their balances, including zeros.", - tags: ['/chain/solana'], - body: SolanaBalanceRequest, - response: { - 200: { - ...BalanceResponseSchema, - description: 'Token balances for the specified address (only tokens in token list)', - examples: [ - { - balances: { - SOL: 1.5, - USDC: 100.0, - BONK: 50000.0, - }, - }, - ], - }, - }, - }, - }, - async (request) => { - const { network, address, tokens } = request.body; - return await getSolanaBalances(fastify, network, address, tokens); - }, - ); -}; - -export default balancesRoute; diff --git a/src/chains/solana/routes/estimate-gas.ts b/src/chains/solana/routes/estimate-gas.ts index fc678ebfd6..c23d798c59 100644 --- a/src/chains/solana/routes/estimate-gas.ts +++ b/src/chains/solana/routes/estimate-gas.ts @@ -1,9 +1,6 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { EstimateGasResponse, EstimateGasResponseSchema } from '../../../schemas/chain-schema'; +import { EstimateGasResponse } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { SolanaEstimateGasRequest, SolanaEstimateGasRequestType } from '../schemas'; import { Solana } from '../solana'; export async function estimateGasSolana(network: string): Promise { @@ -72,29 +69,3 @@ export async function estimateGasSolana(network: string): Promise { - fastify.get<{ - Querystring: SolanaEstimateGasRequestType; - Reply: EstimateGasResponse; - }>( - '/estimate-gas', - { - schema: { - description: - 'Estimate priority fees for Solana transactions. Optionally pass addresses (program IDs, pools) for Helius-specific fee estimation.', - tags: ['/chain/solana'], - querystring: SolanaEstimateGasRequest, - response: { - 200: EstimateGasResponseSchema, - }, - }, - }, - async (request) => { - const { network } = request.query; - return await estimateGasSolana(network); - }, - ); -}; - -export default estimateGasRoute; diff --git a/src/chains/solana/routes/poll.ts b/src/chains/solana/routes/poll.ts index b9f0266d70..d41e8b0229 100644 --- a/src/chains/solana/routes/poll.ts +++ b/src/chains/solana/routes/poll.ts @@ -1,8 +1,7 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { PollRequestType, PollResponseType, PollResponseSchema } from '../../../schemas/chain-schema'; +import { PollResponseType, TransactionStatusCode } from '../../../schemas/chain-schema'; import { logger } from '../../../services/logger'; -import { SolanaPollRequest } from '../schemas'; import { Solana } from '../solana'; import { parseSolanaError } from '../solana-error-parser'; @@ -16,13 +15,15 @@ export async function pollSolanaTransaction( try { const currentBlock = await solana.getCurrentBlockNumber(); - // Validate transaction signature format + // Validate transaction signature format. A malformed signature can never + // resolve, so report NOT_FOUND rather than a pending status a poller would + // wait on forever. if (!signature || typeof signature !== 'string' || !signature.match(/^[A-Za-z0-9]{43,88}$/)) { return { currentBlock, signature, txBlock: null, - txStatus: 0, + txStatus: TransactionStatusCode.NOT_FOUND, fee: null, error: 'INVALID_INPUT: Invalid transaction signature format', txData: null, @@ -32,11 +33,16 @@ export async function pollSolanaTransaction( const txData = await solana.getTransaction(signature); if (!txData) { + // Null txData means either "seen but awaiting confirmation" or "unknown to + // the cluster" (dropped, or never received). Only the signature-status + // cache separates them: UNCONFIRMED is worth polling again, NOT_FOUND is + // terminal once the transaction's blockhash has expired. + const txStatus = await solana.getSignatureStatus(signature); return { currentBlock, signature, txBlock: null, - txStatus: 0, + txStatus, fee: null, error: null, txData: null, @@ -48,10 +54,13 @@ export async function pollSolanaTransaction( // Extract fee from transaction const fee = txData.meta?.fee ? txData.meta.fee / 1e9 : 0; // Convert lamports to SOL - // Check for transaction error and parse it + // Check for transaction error and parse it. The err object carries the code but + // names no program, so parse it together with the program logs — attribution + // comes from the "Program X failed: custom program error" line, and without it + // every program-specific code (e.g. Orca 6018) falls through to UNKNOWN. let error: string | null = null; if (txData.meta?.err) { - const errorStr = JSON.stringify(txData.meta.err); + const errorStr = [JSON.stringify(txData.meta.err), ...(txData.meta.logMessages ?? [])].join('\n'); const parsed = parseSolanaError(errorStr); error = `${parsed.type} (${parsed.errorCodeHex || 'unknown'}): ${parsed.message}`; logger.info(`Transaction ${signature} failed: ${error}`); @@ -69,40 +78,17 @@ export async function pollSolanaTransaction( txData, }; } catch (err) { + // Transient failure (RPC error, etc.) — the transaction's fate is unknown, so + // report pending rather than NOT_FOUND: the caller should poll again, not give up. logger.error(`Error polling transaction ${signature}: ${(err as Error).message}`); return { currentBlock: await solana.getCurrentBlockNumber(), signature, txBlock: null, - txStatus: 0, + txStatus: TransactionStatusCode.PENDING, fee: null, - error: 'Transaction not found or invalid', + error: `Error polling transaction: ${(err as Error).message}`, txData: null, }; } } - -export const pollRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: PollRequestType; - Reply: PollResponseType; - }>( - '/poll', - { - schema: { - description: 'Poll for the status of a Solana transaction', - tags: ['/chain/solana'], - body: SolanaPollRequest, - response: { - 200: PollResponseSchema, - }, - }, - }, - async (request) => { - const { network, signature } = request.body; - return await pollSolanaTransaction(fastify, network, signature); - }, - ); -}; - -export default pollRoute; diff --git a/src/chains/solana/routes/status.ts b/src/chains/solana/routes/status.ts index de0566d5fa..e7e1dfea94 100644 --- a/src/chains/solana/routes/status.ts +++ b/src/chains/solana/routes/status.ts @@ -1,8 +1,7 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { StatusRequestType, StatusResponseType, StatusResponseSchema } from '../../../schemas/chain-schema'; +import { StatusResponseType } from '../../../schemas/chain-schema'; import { logger } from '../../../services/logger'; -import { SolanaStatusRequest } from '../schemas'; import { Solana } from '../solana'; import { getSolanaChainConfig } from '../solana.config'; @@ -43,28 +42,3 @@ export async function getSolanaStatus(fastify: FastifyInstance, network: string) throw fastify.httpErrors.internalServerError(`Failed to get Solana status: ${error.message}`); } } - -export const statusRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: StatusRequestType; - Reply: StatusResponseType; - }>( - '/status', - { - schema: { - description: 'Get Solana network status', - tags: ['/chain/solana'], - querystring: SolanaStatusRequest, - response: { - 200: StatusResponseSchema, - }, - }, - }, - async (request) => { - const { network } = request.query; - return await getSolanaStatus(fastify, network); - }, - ); -}; - -export default statusRoute; diff --git a/src/chains/solana/routes/unwrap.ts b/src/chains/solana/routes/unwrap.ts index 6c8591c015..1bbd62d445 100644 --- a/src/chains/solana/routes/unwrap.ts +++ b/src/chains/solana/routes/unwrap.ts @@ -1,9 +1,9 @@ import { NATIVE_MINT, getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID, AccountLayout } from '@solana/spl-token'; import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { logger } from '../../../services/logger'; -import { UnwrapRequestSchema, UnwrapResponseSchema, UnwrapRequestType, UnwrapResponseType } from '../schemas'; +import { UnwrapResponseType } from '../schemas'; import { Solana } from '../solana'; import { handleSolanaTransactionError } from '../solana-errors'; import { SolanaLedger } from '../solana-ledger'; @@ -134,28 +134,3 @@ export async function unwrapSolana( handleSolanaTransactionError(fastify, error, 'unwrap WSOL to SOL'); } } - -export const unwrapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: UnwrapRequestType; - Reply: UnwrapResponseType; - }>( - '/unwrap', - { - schema: { - description: 'Unwrap WSOL to SOL. Note: This closes the entire WSOL account, returning all WSOL as SOL.', - tags: ['/chain/solana'], - body: UnwrapRequestSchema, - response: { - 200: UnwrapResponseSchema, - }, - }, - }, - async (request) => { - const { network, address, amount } = request.body; - return await unwrapSolana(fastify, network, address, amount); - }, - ); -}; - -export default unwrapRoute; diff --git a/src/chains/solana/routes/wrap.ts b/src/chains/solana/routes/wrap.ts index 5e7d890ded..198e85c48e 100644 --- a/src/chains/solana/routes/wrap.ts +++ b/src/chains/solana/routes/wrap.ts @@ -1,8 +1,8 @@ import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { logger } from '../../../services/logger'; -import { WrapRequestSchema, WrapResponseSchema, WrapRequestType, WrapResponseType } from '../schemas'; +import { WrapResponseType } from '../schemas'; import { Solana } from '../solana'; import { handleSolanaTransactionError } from '../solana-errors'; import { SolanaLedger } from '../solana-ledger'; @@ -98,28 +98,3 @@ export async function wrapSolana( handleSolanaTransactionError(fastify, error, 'wrap SOL to WSOL'); } } - -export const wrapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: WrapRequestType; - Reply: WrapResponseType; - }>( - '/wrap', - { - schema: { - description: 'Wrap SOL to WSOL (Wrapped SOL)', - tags: ['/chain/solana'], - body: WrapRequestSchema, - response: { - 200: WrapResponseSchema, - }, - }, - }, - async (request) => { - const { network, address, amount } = request.body; - return await wrapSolana(fastify, network, address, amount); - }, - ); -}; - -export default wrapRoute; diff --git a/src/chains/solana/schemas.ts b/src/chains/solana/schemas.ts index a023ae2469..eb355f2783 100644 --- a/src/chains/solana/schemas.ts +++ b/src/chains/solana/schemas.ts @@ -8,8 +8,6 @@ const solanaChainConfig = getSolanaChainConfig(); // Example values const EXAMPLE_SIGNATURE = '55ukR6VCt1sQFMC8Nyeo51R1SMaTzUC7jikmkEJ2jjkQNdqBxXHraH7vaoaNmf8rX4Y55EXAj8XXoyzvvsrQqWZa'; const EXAMPLE_TOKENS = ['SOL', 'USDC', 'BONK']; -const USDC_MINT_ADDRESS = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; -const BONK_MINT_ADDRESS = 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'; // Network parameter with proper defaults and enum export const SolanaNetworkParameter = Type.Optional( diff --git a/src/chains/solana/solana-error-parser.ts b/src/chains/solana/solana-error-parser.ts index f8fec795f3..6d58175a0c 100644 --- a/src/chains/solana/solana-error-parser.ts +++ b/src/chains/solana/solana-error-parser.ts @@ -90,8 +90,12 @@ const PROGRAM_ERROR_CODES: Record { - fastify.register(statusRoute); - fastify.register(estimateGasRoute); - fastify.register(balancesRoute); - fastify.register(pollRoute); - fastify.register(wrapRoute); - fastify.register(unwrapRoute); -}; - -export default solanaRoutes; diff --git a/src/chains/solana/solana.ts b/src/chains/solana/solana.ts index 1e9c83167b..feaa9b740e 100644 --- a/src/chains/solana/solana.ts +++ b/src/chains/solana/solana.ts @@ -8,6 +8,7 @@ import { createSyncNativeInstruction, createCloseAccountInstruction, AccountLayout, + getTokenMetadata, } from '@solana/spl-token'; import { TokenInfo } from '@solana/spl-token-registry'; import { @@ -43,6 +44,7 @@ import { ChainstackService } from '../../rpc/chainstack-service'; import { HeliusService } from '../../rpc/helius-service'; import { createRateLimitAwareSolanaConnection } from '../../rpc/rpc-connection-interceptor'; import { RPCProvider } from '../../rpc/rpc-provider-base'; +import { TransactionStatusCode } from '../../schemas/chain-schema'; import { ConfigManagerCertPassphrase } from '../../services/config-manager-cert-passphrase'; import { ConfigManagerV2 } from '../../services/config-manager-v2'; import { httpErrors, HttpError } from '../../services/error-handler'; @@ -51,13 +53,21 @@ import { encryptSecret, decryptSecret, isLegacyKeystore } from '../../services/s import { TokenService } from '../../services/token-service'; import { getSafeWalletFilePath, isHardwareWallet as isHardwareWalletUtil } from '../../wallet/utils'; +import { parseSolanaError } from './solana-error-parser'; import { SolanaLedger } from './solana-ledger'; import { PriorityFeeResult, SolanaPriorityFees } from './solana-priority-fees'; import { SolanaNetworkConfig, getSolanaNetworkConfig, getSolanaChainConfig } from './solana.config'; +import { accountLifecycleSol } from './solana.utils'; export type SolanaWalletType = 'local' | 'hardware'; // Constants used for fee calculations +/** Metaplex Token Metadata program — where every legacy SPL token's name and symbol live. */ +const METAPLEX_METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); + +/** Metadata account header: key (1) + update authority (32) + mint (32), then the strings. */ +const METAPLEX_NAME_OFFSET = 65; + export const BASE_FEE = 5000; const LAMPORT_TO_SOL = 1 / Math.pow(10, 9); @@ -67,12 +77,6 @@ interface TokenAccount { value: any; } -enum TransactionResponseStatusCode { - FAILED = -1, - UNCONFIRMED = 0, - CONFIRMED = 1, -} - export class Solana { public connection: Connection; public network: string; @@ -292,6 +296,117 @@ export class Solana { return token; } + /** + * Read a token's name, symbol and decimals from the chain. + * + * Distinct from getToken, which searches the configured list and, failing that, + * invents a `DUMMY_xxxx` symbol so a swap can still be priced. That placeholder is + * fine to trade on and unfit to persist, so anything that writes to the token list + * comes here instead and gets null when the chain has no name to give. + * + * A mint account carries only decimals. The name and symbol live in one of two + * places, and both are read here because neither covers the other's tokens: the + * Token-2022 metadata extension, which most newly minted tokens use, and the + * Metaplex metadata account, which is where every legacy SPL token keeps them. + */ + async fetchTokenFromChain(address: string): Promise { + let mintPubkey: PublicKey; + try { + mintPubkey = new PublicKey(address); + } catch { + return null; + } + + const accountInfo = await this.connection.getAccountInfo(mintPubkey); + if (!accountInfo) { + return null; + } + + // A well-formed address that is not a mint — a wallet, a pool, a program — is a + // question with an answer ("not a token"), not a failure, so it returns null rather + // than letting getMint's rejection escape to the caller. + const programId = accountInfo.owner.equals(TOKEN_2022_PROGRAM_ID) ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID; + let mintInfo; + try { + mintInfo = await getMint(this.connection, mintPubkey, undefined, programId); + } catch (e: any) { + logger.debug(`${address} on solana is not a mint account: ${e.message}`); + return null; + } + + const metadata = + (await this.readToken2022Metadata(mintPubkey, programId)) ?? (await this.readMetaplexMetadata(mintPubkey)); + if (!metadata) { + logger.info(`No on-chain metadata for mint ${address}; not naming it`); + return null; + } + + return { + address, + chainId: 101, + decimals: mintInfo.decimals, + name: metadata.name, + symbol: metadata.symbol, + }; + } + + /** Name and symbol from the Token-2022 metadata extension. Null for a legacy mint. */ + private async readToken2022Metadata( + mint: PublicKey, + programId: PublicKey, + ): Promise<{ name: string; symbol: string } | null> { + if (!programId.equals(TOKEN_2022_PROGRAM_ID)) { + return null; + } + try { + const metadata = await getTokenMetadata(this.connection, mint, undefined, programId); + if (!metadata?.symbol) { + return null; + } + return { name: metadata.name || metadata.symbol, symbol: metadata.symbol }; + } catch (e: any) { + logger.debug(`Token-2022 metadata unreadable for ${mint.toBase58()}: ${e.message}`); + return null; + } + } + + /** + * Name and symbol from the Metaplex metadata account. + * + * Read directly rather than through the Metaplex SDK, which is not a dependency here: + * the account is at a PDA of ['metadata', program, mint], and the three fields wanted + * are the first Borsh strings after a fixed 65-byte header of key, update authority + * and mint. Each is a 4-byte little-endian length followed by null-padded bytes. + */ + private async readMetaplexMetadata(mint: PublicKey): Promise<{ name: string; symbol: string } | null> { + try { + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from('metadata'), METAPLEX_METADATA_PROGRAM_ID.toBuffer(), mint.toBuffer()], + METAPLEX_METADATA_PROGRAM_ID, + ); + const account = await this.connection.getAccountInfo(pda); + if (!account) { + return null; + } + + const readString = (offset: number): { value: string; next: number } => { + const length = account.data.readUInt32LE(offset); + const raw = account.data.subarray(offset + 4, offset + 4 + length).toString('utf8'); + return { value: raw.replace(/\0/g, '').trim(), next: offset + 4 + length }; + }; + + const name = readString(METAPLEX_NAME_OFFSET); + const symbol = readString(name.next); + if (!symbol.value) { + return null; + } + return { name: name.value || symbol.value, symbol: symbol.value }; + } catch (e: any) { + logger.debug(`Metaplex metadata unreadable for ${mint.toBase58()}: ${e.message}`); + return null; + } + } + // returns Keypair for a private key, which should be encoded in Base58 getKeypairFromPrivateKey(privateKey: string): Keypair { const decoded = bs58.decode(privateKey); @@ -1175,20 +1290,40 @@ export class Solana { }); } - // returns a Solana TransactionResponseStatusCode for a txData. - public async getTransactionStatusCode(txData: TransactionResponse | null): Promise { + // returns a Solana TransactionStatusCode for a txData. + public async getTransactionStatusCode(txData: TransactionResponse | null): Promise { let txStatus; if (!txData) { // tx not yet confirmed by validator - txStatus = TransactionResponseStatusCode.UNCONFIRMED; + txStatus = TransactionStatusCode.PENDING; } else { // If txData exists, check if there's an error in the metadata - txStatus = - txData.meta?.err == null ? TransactionResponseStatusCode.CONFIRMED : TransactionResponseStatusCode.FAILED; + txStatus = txData.meta?.err == null ? TransactionStatusCode.CONFIRMED : TransactionStatusCode.FAILED; } return txStatus; } + // Distinguishes a signature the cluster has seen from one it does not know at all. + // getTransaction (commitment 'confirmed') returns null for both a tx awaiting + // confirmation and a tx that was dropped, so pollers cannot tell them apart from + // txData alone. A signature that stays NOT_FOUND after its blockhash expires + // (~90s) can never land. + public async getSignatureStatus(signature: string): Promise { + const { value } = await this.connection.getSignatureStatuses([signature], { + searchTransactionHistory: true, + }); + const status = value[0]; + if (!status) { + return TransactionStatusCode.NOT_FOUND; + } + if (status.err) { + return TransactionStatusCode.FAILED; + } + // Seen by the cluster: 'processed', or confirmed/finalized racing ahead of + // getTransaction visibility — report unconfirmed and let the next poll resolve it. + return TransactionStatusCode.PENDING; + } + // returns the current block number async getCurrentBlockNumber(): Promise { return await this.connection.getSlot('processed'); @@ -1325,6 +1460,30 @@ export class Solana { return totalFee; } + private static throwIfSimulationReturnedError(simulationResult: { err: unknown; logs?: string[] | null }): void { + if (!simulationResult.err) return; + + const logs = simulationResult.logs ?? []; + const errorMessage = `${SIMULATION_ERROR_MESSAGE}\nError: ${JSON.stringify(simulationResult.err)}\nProgram Logs: ${logs.join('\n')}`; + const parsedError = parseSolanaError(errorMessage); + const detail = [ + parsedError.message, + parsedError.instructionIndex !== null ? `Failing instruction index: ${parsedError.instructionIndex}.` : '', + logs.length > 0 ? `Program logs:\n${logs.slice(-12).join('\n')}` : '', + ] + .filter(Boolean) + .join('\n'); + + logger.error(errorMessage); + if (parsedError.type === 'SLIPPAGE_EXCEEDED') { + throw httpErrors.slippageExceeded(detail); + } + if (parsedError.type === 'INSUFFICIENT_BALANCE') { + throw httpErrors.insufficientBalance(detail); + } + throw httpErrors.simulationFailed(detail); + } + public async sendAndConfirmTransaction( tx: Transaction | VersionedTransaction, signers: Signer[] = [], @@ -1334,10 +1493,9 @@ export class Solana { const currentPriorityFee = priorityFeePerCU ?? (await this.estimateGasPrice()); // Always simulate transaction to get actual compute units - let computeUnitsToUse: number; + let computeUnitsToUse = this.config.defaultComputeUnits; + let simulationResult: { err: unknown; logs?: string[] | null; unitsConsumed?: number } | undefined; try { - let simulationResult; - if (tx instanceof Transaction) { // For regular transactions, simulate with the Transaction object const result = await this.connection.simulateTransaction(tx); @@ -1350,7 +1508,12 @@ export class Solana { }); simulationResult = result.value; } + } catch (error) { + logger.warn(`Failed to simulate for compute units: ${error.message}, using default`); + } + if (simulationResult) { + Solana.throwIfSimulationReturnedError(simulationResult); if (simulationResult.unitsConsumed) { // Add 10% margin for safety computeUnitsToUse = Math.ceil(simulationResult.unitsConsumed * 1.1); @@ -1358,13 +1521,8 @@ export class Solana { `Simulation consumed ${simulationResult.unitsConsumed} units, using ${computeUnitsToUse} with 10% margin`, ); } else { - // Fallback to default if simulation doesn't return units - computeUnitsToUse = this.config.defaultComputeUnits; logger.warn('Simulation did not return units consumed, using default'); } - } catch (error) { - logger.warn(`Failed to simulate for compute units: ${error.message}, using default`); - computeUnitsToUse = this.config.defaultComputeUnits; } const basePriorityFeeLamports = currentPriorityFee * computeUnitsToUse; @@ -1408,28 +1566,54 @@ export class Solana { } /** - * If a broadcast transaction landed on-chain but failed, throw the parsed program error; - * return silently when the transaction is missing or succeeded so the caller can apply - * its own confirmation handling. The confirmation helpers report a landed-and-failed - * transaction as unconfirmed, which callers would otherwise misreport as a timeout. + * If a broadcast transaction landed on-chain but failed, throw the parsed program error + * (400 TRANSACTION_FAILED — fees were paid, this is terminal, not retryable); return + * silently when the transaction is missing or succeeded so the caller can apply its own + * confirmation handling. The confirmation helpers report a landed-and-failed transaction + * as unconfirmed, which callers would otherwise misreport as a timeout or as PENDING. + * + * Pass `txData` when it is already at hand to skip the re-fetch; when the caller only + * has a null/absent txData the transaction is looked up once more, because the send + * helpers can report a landed-and-failed transaction with no data attached. */ - private async throwIfLandedWithError(signature: string): Promise { + public async throwIfLandedWithError(signature: string, txData?: any): Promise { if (!signature) return; - let txData: any = null; - try { - txData = await this.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); - } catch { - return; + if (!txData) { + try { + txData = await this.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + } catch { + return; + } } if (!txData?.meta?.err) return; - const { simulationFailed } = await import('../../services/error-handler'); + throw await this.buildLandedWithErrorException(signature, txData); + } + + /** Build the shared landed-but-failed error from a transaction's on-chain data. */ + private async buildLandedWithErrorException(signature: string, txData: any): Promise { + const { transactionFailed } = await import('../../services/error-handler'); const { parseSolanaError } = await import('./solana-error-parser'); const logs: string[] = txData.meta.logMessages ?? []; const parsed = parseSolanaError([JSON.stringify(txData.meta.err), ...logs].join('\n')); - throw simulationFailed(`Transaction ${signature} landed on-chain but failed: ${parsed.message}`); + return transactionFailed(`Transaction ${signature} landed on-chain but failed: ${parsed.message}`); + } + + /** + * Route-level re-fetch of a just-sent transaction. Uses the retrying fetch (data can lag + * RPC visibility right after confirmation, so a single getTransaction call misreports a + * confirmed transaction as PENDING) and throws the shared landed-but-failed error when + * the transaction landed with an error. Returns null only when the transaction is + * genuinely not visible on-chain yet. + */ + public async getConfirmedTransactionData(signature: string): Promise { + const txData = await this._fetchTransactionWithRetry(signature); + if (txData?.meta?.err != null) { + throw await this.buildLandedWithErrorException(signature, txData); + } + return txData; } public async sendAndConfirmTransactionForWallet( @@ -1469,18 +1653,22 @@ export class Solana { // External wallets (hardware/Ledger): add the compute budget and sign any extra // keypairs, then sign the fee payer externally. const currentPriorityFee = priorityFeePerCU ?? (await this.estimateGasPrice()); - let computeUnitsToUse: number; + let computeUnitsToUse = this.config.defaultComputeUnits; + let simulationResult: { err: unknown; logs?: string[] | null; unitsConsumed?: number } | undefined; try { const sim = tx instanceof VersionedTransaction ? await this.connection.simulateTransaction(tx, { replaceRecentBlockhash: true, sigVerify: false }) : await this.connection.simulateTransaction(tx); - computeUnitsToUse = sim.value.unitsConsumed - ? Math.ceil(sim.value.unitsConsumed * 1.1) - : this.config.defaultComputeUnits; + simulationResult = sim.value; } catch (error) { logger.warn(`Failed to simulate for compute units: ${(error as Error).message}, using default`); - computeUnitsToUse = this.config.defaultComputeUnits; + } + if (simulationResult) { + Solana.throwIfSimulationReturnedError(simulationResult); + computeUnitsToUse = simulationResult.unitsConsumed + ? Math.ceil(simulationResult.unitsConsumed * 1.1) + : this.config.defaultComputeUnits; } let prepared: Transaction | VersionedTransaction; @@ -1947,6 +2135,8 @@ export class Solana { ): Promise<{ balanceChanges: number[]; fee: number; + /** The transaction these were read from, so a caller needing more of it need not refetch. */ + txDetails: any; }> { // Fetch transaction details with retry (data may not be immediately available after confirmation) const txDetails = await this._fetchTransactionWithRetry(signature, 5, 500, true); @@ -1978,9 +2168,15 @@ export class Solana { return 0; } - // Calculate SOL change including fees + // The raw lamport delta bundles the transaction fee in with the trade whenever + // the owner paid it — the fee is debited from the fee payer, which is account 0. + // Callers read this as the amount swapped, deposited or collected, so the fee is + // added back out: it is reported separately as `fee` and must not be counted + // twice. Selling SOL previously overstated amountIn by the fee and understated + // realized price; collecting SOL-denominated fees understated the amount. const lamportChange = postBalances[accountIndex] - preBalances[accountIndex]; - return lamportChange * LAMPORT_TO_SOL; + const solChange = lamportChange * LAMPORT_TO_SOL; + return accountIndex === 0 ? solChange + fee : solChange; } else { // Token mint address provided - get SPL token balance change const preBalance = @@ -1995,7 +2191,7 @@ export class Solana { } }); - return { balanceChanges, fee }; + return { balanceChanges, fee, txDetails }; } /** @@ -2004,7 +2200,6 @@ export class Solana { * @param owner Owner address * @param baseTokenInfo Base token info object with address and symbol * @param quoteTokenInfo Quote token info object with address and symbol - * @param txFee Transaction fee in lamports (from txData.meta.fee) * @returns Object with base and quote token balance changes and calculated rent */ async extractClmmBalanceChanges( @@ -2012,11 +2207,13 @@ export class Solana { owner: string, baseTokenInfo: { address: string; symbol: string }, quoteTokenInfo: { address: string; symbol: string }, - txFee: number, ): Promise<{ baseTokenChange: number; quoteTokenChange: number; + /** Rent locked or refunded by the accounts this transaction created or closed. */ rent: number; + /** Everything the accounts moved, which is what a native balance change must lose. */ + accountSol: number; }> { const SOL_NATIVE_MINT = 'So11111111111111111111111111111111111111112'; const isBaseSol = baseTokenInfo.symbol === 'SOL' || baseTokenInfo.address === SOL_NATIVE_MINT; @@ -2046,29 +2243,35 @@ export class Solana { } // Extract balance changes - const { balanceChanges } = await this.extractBalanceChangesAndFee(signature, owner, tokensToExtract); + const { balanceChanges, txDetails } = await this.extractBalanceChangesAndFee(signature, owner, tokensToExtract); // Get individual balance changes - const solChange = balanceChanges[tokenIndices.sol!]; const baseTokenChange = balanceChanges[tokenIndices.base!]; const quoteTokenChange = balanceChanges[tokenIndices.quote!]; - // Calculate rent from SOL balance - // When neither token is SOL: rent = |SOL change| - fee - // When one token is SOL: rent is included in the token's balance change - let rent = 0; - if (!isBaseSol && !isQuoteSol) { - // SOL change = -(fee + rent) - rent = Math.abs(solChange) - txFee / 1e9; - } else { - // For positions, rent is approximately 0.00204928 SOL - rent = 0.00204928; - } + // Rent is read out of the transaction, not assumed. Every account the transaction + // created or closed moved lamports for a reason that is not liquidity, and a CLMM + // position is several accounts: the position, its NFT account, the shared protocol + // position, and any tick array the range was first to touch. This used to return a + // hardcoded 0.00204928 whenever a side was SOL — one token account's worth, for a + // position that locks four or five accounts' worth — which understated the rent and + // by exactly the same amount overstated the liquidity the native side reported. + // + // `accountRent` is what a route reports as positionRent / positionRentRefunded; + // `accountSol` is the larger figure to take out of a native balance change, and + // differs only when a wrapped-SOL account the wallet already had a balance in was + // closed. Which direction applies is the transaction's to say: an open creates + // accounts, a close closes them, and a swap does neither. + const lifecycle = accountLifecycleSol(txDetails); + const isOpen = lifecycle.opened >= lifecycle.closed; + const rent = isOpen ? lifecycle.rentLocked : lifecycle.rentRefunded; + const accountSol = isOpen ? lifecycle.opened : lifecycle.closed; return { baseTokenChange, quoteTokenChange, rent, + accountSol, }; } @@ -2208,24 +2411,28 @@ export class Solana { /** * Helper function to handle transaction confirmation results - * Returns appropriate response object based on confirmation status + * Returns appropriate response object based on the transaction's on-chain data * @param signature Transaction signature - * @param confirmed Whether transaction was confirmed - * @param txData Transaction data (if available) + * @param txData Transaction data from the route-level re-fetch (pass null when the + * caller has none — the helper re-fetches with retry so a just-confirmed transaction + * whose data lags RPC visibility is not misreported as PENDING) * @param tokenIn Input token address * @param tokenOut Output token address * @param walletAddress Wallet address for balance changes * @param side Trade side (optional, for AMM/CLMM swaps) - * @returns Response object with status and data + * @param slippagePct Slippage tolerance actually applied to the swap (echoed in data) + * @returns Response object with status and data; throws the shared landed-but-failed + * error when the transaction landed on-chain with an error — existence of txData is + * never treated as confirmation by itself */ public async handleConfirmation( signature: string, - confirmed: boolean, txData: any, tokenIn: string, tokenOut: string, walletAddress: string, side?: 'BUY' | 'SELL', + slippagePct?: number, ): Promise<{ signature: string; status: number; @@ -2237,9 +2444,20 @@ export class Solana { fee: number; baseTokenBalanceChange: number; quoteTokenBalanceChange: number; + slippagePct?: number; }; }> { - if (confirmed && txData) { + if (!txData) { + txData = await this._fetchTransactionWithRetry(signature); + } + + // Defense: a landed-but-failed transaction must throw — never report it as + // confirmed (its data exists) or as pending. + if (txData?.meta?.err != null) { + throw await this.buildLandedWithErrorException(signature, txData); + } + + if (txData) { // Transaction confirmed, extract balance changes const { balanceChanges, fee } = await this.extractBalanceChangesAndFee(signature, walletAddress, [ tokenIn, @@ -2278,25 +2496,7 @@ export class Solana { fee, baseTokenBalanceChange: baseTokenBalanceChange!, quoteTokenBalanceChange: quoteTokenBalanceChange!, - }, - }; - } else if (txData && !confirmed) { - // Transaction exists but not confirmed - extract fee from txData - const fee = this.getFee(txData); - - logger.warn(`Transaction ${signature} not confirmed. May need higher priority fee.`); - - return { - signature, - status: -1, // NOT_CONFIRMED - data: { - tokenIn, - tokenOut, - amountIn: 0, - amountOut: 0, - fee, - baseTokenBalanceChange: 0, - quoteTokenBalanceChange: 0, + slippagePct, }, }; } else { diff --git a/src/chains/solana/solana.utils.ts b/src/chains/solana/solana.utils.ts index f114eeb43e..1f326d49c3 100644 --- a/src/chains/solana/solana.utils.ts +++ b/src/chains/solana/solana.utils.ts @@ -1,6 +1,9 @@ import * as fs from 'fs'; import * as path from 'path'; +import { NATIVE_MINT } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; + import { rootPath } from '../../paths'; import { ConfigManagerV2 } from '../../services/config-manager-v2'; @@ -46,3 +49,176 @@ export function getAvailableSolanaNetworks(): string[] { return ['mainnet-beta', 'devnet']; } } + +/** + * The SOL a transaction moved because accounts opened or closed, rather than because + * liquidity did. + * + * Opening a position creates several accounts and every one of them is rent-bearing: on + * DAMM v2 the position, the position NFT's mint and that mint's token account; on a + * PancakeSwap/Raydium CLMM the position, its NFT account, the shared protocol position + * and any tick array the range is the first to touch. All of it is funded by the wallet + * paying for the transaction, so all of it sits inside that wallet's native balance + * change — and none of it is liquidity. Closing gives the same lamports back the same + * way. Subtracting only the position account's own rent, which is what these routes used + * to do, left every other account's rent inside the reported deposit or withdrawal. + * + * `opened` and `closed` are the totals to take out of a native-side balance change. + * `rentLocked` and `rentRefunded` are the rent halves of those totals, and are what a + * route should report as `positionRent` / `positionRentRefunded`. + * + * The two differ for exactly one kind of account: a wrapped-SOL token account carries a + * balance as well as its rent. When a close unwraps one, the lamports that come back are + * its rent *plus* whatever WSOL it already held before the transaction — someone else's + * money as far as this position is concerned. Taking the whole pre-balance out of the + * change is what leaves the true withdrawal behind; calling the whole thing rent would + * not be true. + * + * An account that both opens and closes within the same transaction — a WSOL account + * created to receive a withdrawal and unwrapped in the same breath — is neither, and is + * correctly ignored: its lamports never left the wallet. + * + * Reads only the balance arrays and the token balances, both of which `getTransaction` + * and `getParsedTransaction` return in the same shape, so it works on either. + * Returns SOL, like everything else here that feeds `liquidityWithoutRent`. + */ +export interface AccountLifecycleSol { + /** Lamports, in SOL, locked into accounts this transaction created. */ + opened: number; + /** Lamports, in SOL, returned by accounts this transaction closed. */ + closed: number; + /** The rent share of `opened` — everything but wrapped SOL already held. */ + rentLocked: number; + /** The rent share of `closed`. */ + rentRefunded: number; +} + +export function accountLifecycleSol(txData: any): AccountLifecycleSol { + const pre: number[] = txData?.meta?.preBalances ?? []; + const post: number[] = txData?.meta?.postBalances ?? []; + + const wrapped = (balances: any[]): Record => { + const byIndex: Record = {}; + for (const balance of balances ?? []) { + if (balance?.mint !== NATIVE_MINT.toBase58()) continue; + // WSOL has 9 decimals, so its raw amount is denominated in lamports already. + byIndex[balance.accountIndex] = Number(balance.uiTokenAmount?.amount ?? 0); + } + return byIndex; + }; + const preWrapped = wrapped(txData?.meta?.preTokenBalances); + const postWrapped = wrapped(txData?.meta?.postTokenBalances); + + let opened = 0; + let closed = 0; + let rentLocked = 0; + let rentRefunded = 0; + + for (let i = 0; i < Math.min(pre.length, post.length); i++) { + if (pre[i] === 0 && post[i] > 0) { + opened += post[i]; + rentLocked += post[i] - (postWrapped[i] ?? 0); + } else if (pre[i] > 0 && post[i] === 0) { + closed += pre[i]; + rentRefunded += pre[i] - (preWrapped[i] ?? 0); + } + } + + return { + opened: opened / 1e9, + closed: closed / 1e9, + rentLocked: rentLocked / 1e9, + rentRefunded: rentRefunded / 1e9, + }; +} + +/** + * Token amounts moved by each top-level instruction of one program, in order. + * + * The reason this exists rather than the grouping in `orca.utils`: that one drops an + * instruction that moved nothing, so a caller cannot tell "the first instruction + * collected zero fees" from "the first group IS the principal". Position matters here — + * a close sends collect-then-decrease and reads the two by their place — so every + * matching instruction gets a row, zero-filled. + * + * Reads a parsed transaction (`getParsedTransaction`), which is what + * `extractBalanceChangesAndFee` already fetches. Amounts are returned per requested + * mint, in the order the mints were given, in UI units. + * + * Returns an empty array when the transaction carries no parsed inner instructions, + * which a caller must treat as "unknown", never as "nothing moved". + */ +export function transfersByProgramInstruction(parsedTx: any, programId: string, mints: string[]): number[][] { + const inner = parsedTx?.meta?.innerInstructions ?? []; + const outer = parsedTx?.transaction?.message?.instructions ?? []; + if (!inner.length || !outer.length) return []; + + const decimalsByMint: Record = {}; + const mintByAccount: Record = {}; + for (const balance of [...(parsedTx.meta?.preTokenBalances ?? []), ...(parsedTx.meta?.postTokenBalances ?? [])]) { + const account = parsedTx.transaction.message.accountKeys?.[balance.accountIndex]?.pubkey?.toString(); + if (account && balance.mint) mintByAccount[account] = balance.mint; + if (balance.mint && balance.uiTokenAmount?.decimals !== undefined) { + decimalsByMint[balance.mint] = balance.uiTokenAmount.decimals; + } + } + + const rows: number[][] = []; + for (let index = 0; index < outer.length; index++) { + if (outer[index]?.programId?.toString() !== programId) continue; + + const amounts = mints.map(() => 0); + for (const instruction of inner.find((block: any) => block.index === index)?.instructions ?? []) { + const parsed = instruction.parsed; + if (!parsed) continue; + + let mint: string | undefined; + let raw: string | undefined; + let decimals: number | undefined; + if (parsed.type === 'transferChecked' && parsed.info) { + mint = parsed.info.mint; + raw = parsed.info.tokenAmount?.amount; + decimals = parsed.info.tokenAmount?.decimals; + } else if (parsed.type === 'transfer' && parsed.info) { + raw = parsed.info.amount; + mint = mintByAccount[parsed.info.source] ?? mintByAccount[parsed.info.destination]; + } + if (!mint || raw === undefined) continue; + + const position = mints.indexOf(mint); + if (position === -1) continue; + amounts[position] += Number(raw) / 10 ** (decimals ?? decimalsByMint[mint] ?? 0); + } + rows.push(amounts); + } + return rows; +} + +/** + * The liquidity in a wallet balance change, with the account lamports taken out of it. + * + * When a pool side IS the native token, the wallet's balance change for that side carries + * the position's rent as well as the liquidity: on the way in it was locked alongside the + * deposit, on the way out it came back alongside the withdrawal. Rent is not liquidity and + * not a cost — the chain returns it when the accounts close — so reporting the raw change + * overstates what the position holds. On a small position that is the larger of the two + * numbers. + * + * Both directions use this, with `accountLifecycleSol().opened` at open and `.closed` at + * close; the arithmetic is the same because the sign is taken off first. Those totals + * rather than the rent halves, because a wrapped-SOL account closing also hands back a + * balance the wallet already held, which is no more this position's liquidity than its + * rent is. A non-native side never carries either, so it passes through as a magnitude. + * + * Clamps at zero: a native change smaller than what the accounts moved means they + * dominated the transaction, and "nothing was deposited" is the truthful reading of that. + * A negative amount would be the arithmetic leaking into a field that means a quantity of + * tokens. + * + * `accountSol` must be in SOL, as `accountLifecycleSol` returns it — the change is + * denominated in tokens, so a lamport figure here would clamp every native side to zero + * in silence. + */ +export function liquidityWithoutRent(change: number, mint: PublicKey, accountSol: number): number { + return mint.equals(NATIVE_MINT) ? Math.max(0, Math.abs(change) - accountSol) : Math.abs(change); +} diff --git a/src/config/routes/getConnectors.ts b/src/config/routes/getConnectors.ts index c6eed4dd2c..446a0b5576 100644 --- a/src/config/routes/getConnectors.ts +++ b/src/config/routes/getConnectors.ts @@ -1,14 +1,13 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { PancakeswapConfig } from '#src/connectors/pancakeswap/pancakeswap.config'; - import { ZeroXConfig } from '../../connectors/0x/0x.config'; import { DFlowConfig } from '../../connectors/dflow/dflow.config'; import { JupiterConfig } from '../../connectors/jupiter/jupiter.config'; import { MeteoraConfig } from '../../connectors/meteora/meteora.config'; import { OkxConfig } from '../../connectors/okx/okx.config'; import { OrcaConfig } from '../../connectors/orca/orca.config'; +import { PancakeswapConfig } from '../../connectors/pancakeswap/pancakeswap.config'; import { PancakeswapSolConfig } from '../../connectors/pancakeswap-sol/pancakeswap-sol.config'; import { RaydiumConfig } from '../../connectors/raydium/raydium.config'; import { TitanConfig } from '../../connectors/titan/titan.config'; diff --git a/src/config/utils.ts b/src/config/utils.ts index 0a608192eb..8b362ed133 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -1,8 +1,4 @@ -import * as fs from 'fs'; -import * as path from 'path'; - import { FastifyInstance } from 'fastify'; -import * as yaml from 'js-yaml'; import { ConfigManagerV2 } from '../services/config-manager-v2'; import { logger } from '../services/logger'; @@ -11,10 +7,13 @@ import { logger } from '../services/logger'; const KNOWN_CHAINS = ['solana', 'ethereum']; /** - * Parse a chain-network namespace format into chain and network components. - * Returns null if not a chain-network format. + * Is this config namespace a chain-network one, and if so which? + * + * Distinct from services/chain-network's parser, which reads a caller's selector and + * rejects a malformed one. This classifies a namespace against the known chains and + * answers null for anything else — `server`, `uniswap` — which is not an error here. */ -function parseChainNetwork(namespace: string): { chain: string; network: string } | null { +function parseChainNetworkNamespace(namespace: string): { chain: string; network: string } | null { for (const chain of KNOWN_CHAINS) { if (namespace.startsWith(`${chain}-`)) { const network = namespace.slice(chain.length + 1); @@ -36,7 +35,7 @@ export const getConfig = (fastify: FastifyInstance, namespace?: string): object } // Check if this is a chain-network format (e.g., solana-mainnet-beta) - const parsed = parseChainNetwork(namespace); + const parsed = parseChainNetworkNamespace(namespace); if (parsed) { // Get the parent chain config and merge it const chainConfig = ConfigManagerV2.getInstance().getNamespace(parsed.chain); @@ -65,7 +64,7 @@ export const updateConfig = (fastify: FastifyInstance, configPath: string, confi const [namespace, ...pathParts] = configPath.split('.'); const field = pathParts[0]; - const parsed = parseChainNetwork(namespace); + const parsed = parseChainNetworkNamespace(namespace); if (parsed && field) { // Check if this field exists in the chain config (not network config) const chainConfig = ConfigManagerV2.getInstance().getNamespace(parsed.chain); diff --git a/src/connectors/0x/0x.routes.ts b/src/connectors/0x/0x.routes.ts deleted file mode 100644 index 88b754c30e..0000000000 --- a/src/connectors/0x/0x.routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FastifyInstance } from 'fastify'; - -import zeroXRouterRoutes from './router-routes'; - -export const register0xRoutes = async (fastify: FastifyInstance): Promise => { - // Register v2 router routes (4 endpoints) - await fastify.register(zeroXRouterRoutes, { - prefix: '/connectors/0x/router', - }); -}; diff --git a/src/connectors/0x/0x.ts b/src/connectors/0x/0x.ts index 930cb4794c..90e2aafcff 100644 --- a/src/connectors/0x/0x.ts +++ b/src/connectors/0x/0x.ts @@ -215,7 +215,6 @@ export class ZeroX { public parseTokenAmount(amount: number, decimals: number): string { // Convert a decimal amount to the token's smallest unit - const multiplier = BigNumber.from(10).pow(decimals); const amountStr = amount.toFixed(decimals); const [whole, decimal = ''] = amountStr.split('.'); const paddedDecimal = decimal.padEnd(decimals, '0'); diff --git a/src/connectors/0x/router-routes/executeQuote.ts b/src/connectors/0x/router-routes/executeQuote.ts index f49f185496..9825350748 100644 --- a/src/connectors/0x/router-routes/executeQuote.ts +++ b/src/connectors/0x/router-routes/executeQuote.ts @@ -1,21 +1,13 @@ import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { ZeroX } from '../0x'; -import { ZeroXExecuteQuoteRequest } from '../schemas'; - -async function executeQuote( - walletAddress: string, - network: string, - quoteId: string, - gasPrice?: string, - maxGas?: number, -): Promise { + +async function executeQuote(walletAddress: string, network: string, quoteId: string): Promise { // Retrieve cached quote from global cache const quote = quoteCache.get(quoteId); if (!quote) { @@ -56,8 +48,7 @@ async function executeQuote( to: quote.to, data: quote.data, value: quote.value, - gasLimit: maxGas || parseInt(quote.estimatedGas || quote.gas), - ...(gasPrice && { gasPrice: BigNumber.from(gasPrice) }), + gasLimit: parseInt(quote.estimatedGas || quote.gas), }; const txResponse = await wallet.sendTransaction(txData); @@ -82,6 +73,9 @@ async function executeQuote( quote.buyTokenAddress, expectedAmountIn, expectedAmountOut, + undefined, + undefined, + quoteCache.getRequest(quoteId)?.slippagePct, ); // Handle different transaction states @@ -108,34 +102,3 @@ async function executeQuote( } export { executeQuote }; - -export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteQuoteRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-quote', - { - schema: { - description: 'Execute a previously fetched quote from 0x', - tags: ['/connector/0x'], - body: ZeroXExecuteQuoteRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, quoteId, gasPrice, maxGas } = - request.body as typeof ZeroXExecuteQuoteRequest._type; - - return await executeQuote(walletAddress, network, quoteId, gasPrice, maxGas); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing 0x quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeQuoteRoute; diff --git a/src/connectors/0x/router-routes/executeSwap.ts b/src/connectors/0x/router-routes/executeSwap.ts index 8e9aa34beb..3e66fb1795 100644 --- a/src/connectors/0x/router-routes/executeSwap.ts +++ b/src/connectors/0x/router-routes/executeSwap.ts @@ -1,10 +1,5 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; -import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { ZeroXConfig } from '../0x.config'; -import { ZeroXExecuteSwapRequest } from '../schemas'; import { executeQuote } from './executeQuote'; import { quoteSwap } from './quoteSwap'; @@ -17,8 +12,6 @@ async function executeSwap( amount: number, side: 'BUY' | 'SELL', slippagePct: number = ZeroXConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { // Step 1: Get a fresh firm quote using the quoteSwap function const quoteResult = await quoteSwap( @@ -33,50 +26,9 @@ async function executeSwap( ); // Step 2: Execute the quote immediately using executeQuote function - const executeResult = await executeQuote(walletAddress, network, quoteResult.quoteId, gasPrice, maxGas); + const executeResult = await executeQuote(walletAddress, network, quoteResult.quoteId); return executeResult; } export { executeSwap }; - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Quote and execute a token swap on 0x in one step', - tags: ['/connector/0x'], - body: ZeroXExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct, gasPrice, maxGas } = - request.body as typeof ZeroXExecuteSwapRequest._type; - - return await executeSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - gasPrice, - maxGas, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing 0x swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/0x/router-routes/index.ts b/src/connectors/0x/router-routes/index.ts deleted file mode 100644 index e226067b2e..0000000000 --- a/src/connectors/0x/router-routes/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import executeQuoteRoute from './executeQuote'; -import executeSwapRoute from './executeSwap'; -import quoteSwapRoute from './quoteSwap'; - -export const zeroXRouterRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(quoteSwapRoute); - await fastify.register(executeQuoteRoute); - await fastify.register(executeSwapRoute); -}; - -export default zeroXRouterRoutes; diff --git a/src/connectors/0x/router-routes/quoteSwap.ts b/src/connectors/0x/router-routes/quoteSwap.ts index 8a852a027b..55f624ef87 100644 --- a/src/connectors/0x/router-routes/quoteSwap.ts +++ b/src/connectors/0x/router-routes/quoteSwap.ts @@ -1,17 +1,14 @@ import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { QuoteSwapRequestType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { ZeroX } from '../0x'; import { ZeroXConfig } from '../0x.config'; -import { ZeroXQuoteSwapRequest, ZeroXQuoteSwapResponse } from '../schemas'; - +import { ZeroXQuoteSwapResponse } from '../schemas'; async function quoteSwap( network: string, baseToken: string, @@ -167,56 +164,5 @@ async function quoteSwap( export { quoteSwap }; -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: Static; - }>( - '/quote-swap', - { - schema: { - description: - 'Get a swap quote from 0x. Use indicativePrice=true for price discovery only, or false/undefined for executable quotes', - tags: ['/connector/0x'], - querystring: ZeroXQuoteSwapRequest, - response: { 200: ZeroXQuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, baseToken, quoteToken, amount, side, slippagePct, indicativePrice, takerAddress } = - request.query as typeof ZeroXQuoteSwapRequest._type; - - return await quoteSwap( - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - indicativePrice ?? true, - takerAddress, - ); - } catch (e: any) { - if (e.statusCode) throw e; - logger.error('Error getting 0x quote:', e.message || e); - - // Handle specific error cases - if (e.message?.includes('0x API key not configured')) { - throw httpErrors.badRequest(e.message); - } - if (e.message?.includes('0x API Error')) { - throw httpErrors.badRequest(e.message); - } - - // Return the actual error message instead of generic one - throw httpErrors.internalServerError(e.message || 'Failed to get quote'); - } - }, - ); -}; - // Export quote cache for use in execute-quote export { quoteCache }; - -export default quoteSwapRoute; diff --git a/src/connectors/0x/schemas.ts b/src/connectors/0x/schemas.ts index dbd0a0301f..01512c3f3c 100644 --- a/src/connectors/0x/schemas.ts +++ b/src/connectors/0x/schemas.ts @@ -1,65 +1,4 @@ -import { Type, Static } from '@sinclair/typebox'; - -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import * as Base from '../../schemas/router-schema'; - -import { ZeroXConfig } from './0x.config'; - -// Get chain config for defaults -const ethereumChainConfig = getEthereumChainConfig(); - -// Constants for examples -const BASE_TOKEN = 'WETH'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 1; - -// 0x-specific quote-swap request (superset of base QuoteSwapRequest) -export const ZeroXQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...ZeroXConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'First token in the trading pair', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Second token in the trading pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - examples: [1], - }), - ), - indicativePrice: Type.Optional( - Type.Boolean({ - description: - 'If true, returns indicative pricing only (no commitment). If false, returns firm quote ready for execution', - default: true, - }), - ), - takerAddress: Type.Optional( - Type.String({ - description: 'Ethereum wallet address that will execute the swap (optional for quotes)', - }), - ), -}); +import { Type } from '@sinclair/typebox'; // 0x-specific quote-swap response (superset of base QuoteSwapResponse) export const ZeroXQuoteSwapResponse = Type.Object({ @@ -124,91 +63,3 @@ export const ZeroXQuoteSwapResponse = Type.Object({ }), ), }); - -// 0x-specific execute-quote request (superset of base ExecuteQuoteRequest) -export const ZeroXExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...ZeroXConfig.networks], - examples: [...ZeroXConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [1000000], - }), - ), -}); - -// 0x-specific execute-swap request (superset of base ExecuteSwapRequest) -export const ZeroXExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - examples: [ethereumChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...ZeroXConfig.networks], - examples: [...ZeroXConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other token in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - examples: [1], - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); diff --git a/src/connectors/clmm-v3-utils.ts b/src/connectors/clmm-v3-utils.ts new file mode 100644 index 0000000000..de831a1340 --- /dev/null +++ b/src/connectors/clmm-v3-utils.ts @@ -0,0 +1,138 @@ +import { Contract } from '@ethersproject/contracts'; + +import { BinLiquidity } from '../schemas/clmm-schema'; + +/** + * Per-bin liquidity distribution around the current tick for a Uniswap-V3-style + * pool. Shared by the Uniswap and PancakeSwap connectors — PancakeSwap V3 is a + * Uniswap V3 fork with identical tick semantics, so the walk is the same; each + * connector passes its own SDK's math implementations rather than depending on + * the other's. + * + * V3 has no equivalent of Orca's "fetch all positions for pool" RPC — positions + * are NFTs on the position manager. Instead we walk the pool's per-tick + * liquidity profile directly: + * + * 1. Read `liquidityNet` at every bin boundary in the window via parallel + * `pool.ticks(tick)` reads (one eth_call each, fired with Promise.all). + * Ticks that have never been initialized return zeros — that's harmless. + * 2. Start with the pool's active L = pool.liquidity() in the bin that + * contains the current tick. Propagate L outward by adding/subtracting + * `liquidityNet` at each boundary crossed (per the V3 spec). + * 3. For each bin, convert L → (amount0, amount1) via + * getAmount{0,1}Delta(sqrtA, sqrtB, L, false), splitting at the pool's + * current sqrtPriceX96 when the bin straddles the active tick. + * 4. Map to base/quote using `isBaseToken0`, scale by decimals. + * + * Output shape mirrors Meteora's `pool-info.bins[]`. + */ + +// Structural types for the V3 SDK math, in native bigint. The SDKs disagree on +// their numeric type — @uniswap/v3-sdk is JSBI-based, @pancakeswap/v3-sdk uses +// native bigint — so each connector adapts its own SDK to this interface rather +// than one connector importing the other's math. +export interface V3TickMath { + getSqrtRatioAtTick(tick: number): bigint; +} + +export interface V3SqrtPriceMath { + getAmount0Delta(sqrtRatioAX96: bigint, sqrtRatioBX96: bigint, liquidity: bigint, roundUp: boolean): bigint; + getAmount1Delta(sqrtRatioAX96: bigint, sqrtRatioBX96: bigint, liquidity: bigint, roundUp: boolean): bigint; +} + +export async function computeV3BinDistribution(args: { + poolContract: Contract; + tickSpacing: number; + currentTick: number; + currentSqrtPriceX96: bigint; + activeLiquidity: bigint; + decimals0: number; + decimals1: number; + isBaseToken0: boolean; + binCount: number; + tickMath: V3TickMath; + sqrtPriceMath: V3SqrtPriceMath; +}): Promise { + const { + poolContract, + tickSpacing, + currentTick, + currentSqrtPriceX96, + activeLiquidity, + decimals0, + decimals1, + isBaseToken0, + binCount, + tickMath, + sqrtPriceMath, + } = args; + if (binCount <= 0) return []; + + const halfBins = Math.floor(binCount / 2); + const snapped = Math.floor(currentTick / tickSpacing) * tickSpacing; + const firstBinStart = snapped - halfBins * tickSpacing; + const boundaries: number[] = []; + for (let i = 0; i <= binCount; i++) { + boundaries.push(firstBinStart + i * tickSpacing); + } + + // Parallel reads of pool.ticks(tick) at each boundary. Non-initialized + // ticks return zeros which is the correct neutral element for liquidityNet. + const tickData = await Promise.all( + boundaries.map((tick) => poolContract.ticks(tick).catch(() => ({ liquidityNet: 0 }))), + ); + + const curIdx = Math.floor((currentTick - firstBinStart) / tickSpacing); + + // Propagate L outward from the current bin (V3 spec: crossing a tick going + // UP adds liquidityNet, going DOWN subtracts it). + const binLs: bigint[] = new Array(binCount); + binLs[curIdx] = activeLiquidity; + for (let i = curIdx + 1; i < binCount; i++) { + const net = BigInt(tickData[i].liquidityNet.toString()); + binLs[i] = binLs[i - 1] + net; + } + for (let i = curIdx - 1; i >= 0; i--) { + const net = BigInt(tickData[i + 1].liquidityNet.toString()); + binLs[i] = binLs[i + 1] - net; + } + + const scale0 = Math.pow(10, decimals0); + const scale1 = Math.pow(10, decimals1); + const zero = 0n; + const bins: BinLiquidity[] = []; + for (let i = 0; i < binCount; i++) { + const tickStart = boundaries[i]; + const tickEnd = boundaries[i + 1]; + const L = binLs[i]; + let amount0: bigint = zero; + let amount1: bigint = zero; + if (L > zero) { + const sqrtA = tickMath.getSqrtRatioAtTick(tickStart); + const sqrtB = tickMath.getSqrtRatioAtTick(tickEnd); + if (currentTick >= tickEnd) { + amount1 = sqrtPriceMath.getAmount1Delta(sqrtA, sqrtB, L, false); + } else if (currentTick < tickStart) { + amount0 = sqrtPriceMath.getAmount0Delta(sqrtA, sqrtB, L, false); + } else { + amount0 = sqrtPriceMath.getAmount0Delta(currentSqrtPriceX96, sqrtB, L, false); + amount1 = sqrtPriceMath.getAmount1Delta(sqrtA, currentSqrtPriceX96, L, false); + } + } + const amt0 = parseFloat(amount0.toString()) / scale0; + const amt1 = parseFloat(amount1.toString()) / scale1; + const baseTokenAmount = isBaseToken0 ? amt0 : amt1; + const quoteTokenAmount = isBaseToken0 ? amt1 : amt0; + + // Price at tickStart in human units (quote/base regardless of token order). + const rawT1PerT0 = Math.pow(1.0001, tickStart) * Math.pow(10, decimals0 - decimals1); + const price = isBaseToken0 ? rawT1PerT0 : 1 / rawT1PerT0; + bins.push({ + binId: tickStart, + price, + baseTokenAmount, + quoteTokenAmount, + }); + } + return bins; +} diff --git a/src/connectors/dflow/README.md b/src/connectors/dflow/README.md index a5b399e2bc..62bf311dce 100644 --- a/src/connectors/dflow/README.md +++ b/src/connectors/dflow/README.md @@ -1,11 +1,12 @@ # DFlow Router Connector -[DFlow](https://dflow.net) is a low-latency DEX aggregator built for Solana. This connector -exposes DFlow through Gateway's standard router endpoints: +[DFlow](https://dflow.net) is a low-latency DEX aggregator built for Solana. Like every +router connector it is reached through the unified trading routes, naming `dflow` as the +`connector`: -- `GET /connectors/dflow/router/quote-swap` -- `POST /connectors/dflow/router/execute-quote` -- `POST /connectors/dflow/router/execute-swap` +- `GET /trading/router/quote-swap` +- `POST /trading/router/execute-quote` +- `POST /trading/router/execute-swap` Network support: `mainnet-beta` only. diff --git a/src/connectors/dflow/dflow.routes.ts b/src/connectors/dflow/dflow.routes.ts deleted file mode 100644 index 31f71248b9..0000000000 --- a/src/connectors/dflow/dflow.routes.ts +++ /dev/null @@ -1,26 +0,0 @@ -import sensible from '@fastify/sensible'; -import type { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { dflowRouterRoutes } from './router-routes'; - -// DFlow routes with 3 endpoints -const dflowRouterRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - // Decorate the instance with a hook to modify route options - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/dflow']; - } - }); - - await instance.register(dflowRouterRoutes); - }); -}; - -// Export routes in the same pattern as Jupiter -export const dflowRoutes = { - router: dflowRouterRoutesWrapper, -}; diff --git a/src/connectors/dflow/router-routes/executeQuote.ts b/src/connectors/dflow/router-routes/executeQuote.ts index 8cfba7e664..665f7b2fb9 100644 --- a/src/connectors/dflow/router-routes/executeQuote.ts +++ b/src/connectors/dflow/router-routes/executeQuote.ts @@ -1,12 +1,9 @@ -import { FastifyPluginAsync } from 'fastify'; - import { Solana } from '../../../chains/solana/solana'; -import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { DFlow } from '../dflow'; -import { DFlowExecuteQuoteRequest } from '../schemas'; export async function executeQuote( walletAddress: string, @@ -22,7 +19,7 @@ export async function executeQuote( const solana = await Solana.getInstance(network); const dflow = await DFlow.getInstance(network); - const { inputToken, outputToken, quoteResponse } = cached; + const { inputToken, outputToken, quoteResponse, slippagePct } = cached; // Build the swap UNSIGNED with the wallet as authority, then sign + send via the // wallet-type-aware chokepoint (local keypair / Ledger) — no per-wallet-type branching here. @@ -32,18 +29,18 @@ export async function executeQuote( const transaction = await dflow.buildSwapTransactionUnsigned(walletAddress, quoteResponse); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + slippagePct, ); // Remove quote from cache only after successful execution (confirmed) @@ -56,33 +53,3 @@ export async function executeQuote( return result as SwapExecuteResponseType; } - -export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteQuoteRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-quote', - { - schema: { - description: 'Execute a previously fetched quote from DFlow', - tags: ['/connector/dflow'], - body: DFlowExecuteQuoteRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, quoteId } = request.body as typeof DFlowExecuteQuoteRequest._type; - - return await executeQuote(walletAddress, network, quoteId); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing DFlow quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeQuoteRoute; diff --git a/src/connectors/dflow/router-routes/executeSwap.ts b/src/connectors/dflow/router-routes/executeSwap.ts index 312901b915..9ea1df357c 100644 --- a/src/connectors/dflow/router-routes/executeSwap.ts +++ b/src/connectors/dflow/router-routes/executeSwap.ts @@ -1,10 +1,5 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; -import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { DFlowConfig } from '../dflow.config'; -import { DFlowExecuteSwapRequest } from '../schemas'; import { executeQuote } from './executeQuote'; import { quoteSwap } from './quoteSwap'; @@ -35,43 +30,3 @@ async function executeSwap( } export { executeSwap }; - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Quote and execute a token swap on DFlow in one step', - tags: ['/connector/dflow'], - body: DFlowExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut } = - request.body as typeof DFlowExecuteSwapRequest._type; - - return await executeSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - approximateIfNoExactOut, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing DFlow swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/dflow/router-routes/index.ts b/src/connectors/dflow/router-routes/index.ts deleted file mode 100644 index bed0d187f1..0000000000 --- a/src/connectors/dflow/router-routes/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import executeQuoteRoute from './executeQuote'; -import executeSwapRoute from './executeSwap'; -import quoteSwapRoute from './quoteSwap'; - -export const dflowRouterRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(quoteSwapRoute); - await fastify.register(executeQuoteRoute); - await fastify.register(executeSwapRoute); -}; - -export default dflowRouterRoutes; diff --git a/src/connectors/dflow/router-routes/quoteSwap.ts b/src/connectors/dflow/router-routes/quoteSwap.ts index 9bf093739d..11390459da 100644 --- a/src/connectors/dflow/router-routes/quoteSwap.ts +++ b/src/connectors/dflow/router-routes/quoteSwap.ts @@ -1,18 +1,15 @@ import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapRequestType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { sanitizeErrorMessage, sanitizeString } from '../../../services/sanitize'; -import { approximateBuyViaSellLeg } from '../../router-utils'; +import { approximateBuyViaSellLeg, attemptedRoute, priceImpactPercentFromFraction } from '../../router-utils'; import { DFlow, DFlowQuoteResponse } from '../dflow'; import { DFlowConfig } from '../dflow.config'; -import { DFlowQuoteSwapRequest, DFlowQuoteSwapResponse } from '../schemas'; - +import { DFlowQuoteSwapResponse } from '../schemas'; export async function quoteSwap( network: string, baseToken: string, @@ -48,8 +45,8 @@ export async function quoteSwap( try { quoteResponse = await dflow.getQuote(inputToken.address, outputToken.address, amountRaw, slippageBps); } catch (error) { - const tokenPair = `${sanitizeString(baseToken)} -> ${sanitizeString(quoteToken)}`; - throw httpErrors.noRouteFound(`No route found for ${tokenPair} (ExactIn). ${error?.message || error}`); + const route = attemptedRoute(side, sanitizeString(baseToken), sanitizeString(quoteToken)); + throw httpErrors.noRouteFound(`No route found for ${route}. ${error?.message || error}`); } } else { // DFlow is ExactIn-only (it silently ignores swapMode and quotes ExactIn, verified @@ -106,49 +103,13 @@ export async function quoteSwap( amountIn: side === 'SELL' ? amount : estimatedAmountIn, amountOut: estimatedAmountOut, price, - priceImpactPct: parseFloat(quoteResponse.priceImpactPct || '0'), + // DFlow serves Jupiter's quote schema field for field — same name, same string type, + // same siblings — so its priceImpactPct is a fraction too. Inferred from the schema + // rather than measured: the public quote endpoint refuses an unkeyed request. + priceImpactPct: priceImpactPercentFromFraction(quoteResponse.priceImpactPct), minAmountOut, maxAmountIn, ...(isApproximation ? { approximation: true } : {}), quoteResponse, }; } - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: Static; - }>( - '/quote-swap', - { - schema: { - description: 'Get an executable swap quote from DFlow', - tags: ['/connector/dflow'], - querystring: DFlowQuoteSwapRequest, - response: { 200: DFlowQuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut } = - request.query as typeof DFlowQuoteSwapRequest._type; - - return await quoteSwap( - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - approximateIfNoExactOut, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error getting DFlow quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/connectors/dflow/schemas.ts b/src/connectors/dflow/schemas.ts index 5398472c47..22265a6911 100644 --- a/src/connectors/dflow/schemas.ts +++ b/src/connectors/dflow/schemas.ts @@ -1,60 +1,8 @@ import { Type } from '@sinclair/typebox'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -import { DFlowConfig } from './dflow.config'; - // Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); // Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.1; - -// DFlow-specific quote-swap request (superset of base QuoteSwapRequest) -export const DFlowQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...DFlowConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: DFlowConfig.config.slippagePct, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders: DFlow is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', - default: true, - }), - ), -}); // DFlow-specific quote-swap response (superset of base QuoteSwapResponse) export const DFlowQuoteSwapResponse = Type.Object({ @@ -95,74 +43,3 @@ export const DFlowQuoteSwapResponse = Type.Object({ description: "DFlow's native quote response, used to build the swap transaction at execution time", }), }); - -// DFlow-specific execute-quote request (superset of base ExecuteQuoteRequest) -export const DFlowExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...DFlowConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the DFlow quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), -}); - -// DFlow-specific execute-swap request (superset of base ExecuteSwapRequest) -export const DFlowExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...DFlowConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: DFlowConfig.config.slippagePct, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders: DFlow is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', - default: true, - }), - ), -}); diff --git a/src/connectors/evm-slippage.ts b/src/connectors/evm-slippage.ts new file mode 100644 index 0000000000..19da51b350 --- /dev/null +++ b/src/connectors/evm-slippage.ts @@ -0,0 +1,14 @@ +/** + * A slippage percentage as the basis-point numerator the EVM SDKs' `Percent(n, 10000)` + * takes. + * + * Uniswap and PancakeSwap each ship their own `Percent` class, so this returns the + * numerator rather than a Percent: the call site builds its own SDK's type. The reason it + * is a function at all is that the four CLMM liquidity routes used to write + * `new Percent(100, 10000)` — a flat 1% that ignored both the caller's slippagePct and the + * operator's configured one. Rounding to whole basis points is what the denominator + * implies; a tolerance finer than 0.01% is not expressible against it. + */ +export function slippageBasisPoints(slippagePct: number): number { + return Math.round(slippagePct * 100); +} diff --git a/src/connectors/jupiter/jupiter.routes.ts b/src/connectors/jupiter/jupiter.routes.ts deleted file mode 100644 index f3968bb33c..0000000000 --- a/src/connectors/jupiter/jupiter.routes.ts +++ /dev/null @@ -1,26 +0,0 @@ -import sensible from '@fastify/sensible'; -import type { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { jupiterRouterRoutes } from './router-routes'; - -// Jupiter routes with 4 endpoints -const jupiterRouterRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - // Decorate the instance with a hook to modify route options - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/jupiter']; - } - }); - - await instance.register(jupiterRouterRoutes); - }); -}; - -// Export routes in the same pattern as Raydium -export const jupiterRoutes = { - router: jupiterRouterRoutesWrapper, -}; diff --git a/src/connectors/jupiter/router-routes/executeQuote.ts b/src/connectors/jupiter/router-routes/executeQuote.ts index 6c289efcd8..4325320dbc 100644 --- a/src/connectors/jupiter/router-routes/executeQuote.ts +++ b/src/connectors/jupiter/router-routes/executeQuote.ts @@ -1,19 +1,14 @@ -import { FastifyPluginAsync } from 'fastify'; - import { Solana } from '../../../chains/solana/solana'; -import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { Jupiter } from '../jupiter'; -import { JupiterExecuteQuoteRequest } from '../schemas'; export async function executeQuote( walletAddress: string, network: string, quoteId: string, - priorityLevel?: string, - maxLamports?: number, ): Promise { // Retrieve cached quote const quote = quoteCache.get(quoteId); @@ -38,27 +33,22 @@ export async function executeQuote( logger.info( `Executing quote ${quoteId} for ${inputToken.symbol} -> ${outputToken.symbol}, slippageBps=${quote.slippageBps}`, ); - const transaction = await jupiter.buildSwapTransactionForHardwareWallet( - walletAddress, - quote, - maxLamports, - priorityLevel, - ); + const transaction = await jupiter.buildSwapTransactionForHardwareWallet(walletAddress, quote); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); // Handle confirmation status const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + quote.slippageBps != null ? quote.slippageBps / 100 : undefined, ); // Remove quote from cache only after successful execution (confirmed) @@ -71,34 +61,3 @@ export async function executeQuote( return result as SwapExecuteResponseType; } - -export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteQuoteRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-quote', - { - schema: { - description: 'Execute a previously fetched quote from Jupiter', - tags: ['/connector/jupiter'], - body: JupiterExecuteQuoteRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, quoteId, priorityLevel, maxLamports } = - request.body as typeof JupiterExecuteQuoteRequest._type; - - return await executeQuote(walletAddress, network, quoteId, priorityLevel, maxLamports); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeQuoteRoute; diff --git a/src/connectors/jupiter/router-routes/executeSwap.ts b/src/connectors/jupiter/router-routes/executeSwap.ts index 078170b326..daeb1ae980 100644 --- a/src/connectors/jupiter/router-routes/executeSwap.ts +++ b/src/connectors/jupiter/router-routes/executeSwap.ts @@ -1,10 +1,5 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; -import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { JupiterConfig } from '../jupiter.config'; -import { JupiterExecuteSwapRequest } from '../schemas'; import { executeQuote } from './executeQuote'; import { quoteSwap } from './quoteSwap'; @@ -17,8 +12,6 @@ async function executeSwap( amount: number, side: 'BUY' | 'SELL', slippagePct: number = JupiterConfig.config.slippagePct, - priorityLevel?: string, - maxLamports?: number, approximateIfNoExactOut: boolean = true, ): Promise { // Step 1: Get a fresh quote using the quoteSwap function @@ -29,73 +22,13 @@ async function executeSwap( amount, side, slippagePct, - undefined, - undefined, approximateIfNoExactOut, ); - // Step 2: Execute the quote immediately using executeQuote function - const executeResult = await executeQuote( - walletAddress, - network, - quoteResult.quoteId, - priorityLevel ?? JupiterConfig.config.priorityLevel, - maxLamports ?? JupiterConfig.config.maxLamports, - ); + // Step 2: Execute the quote immediately (priority fees come from the connector config) + const executeResult = await executeQuote(walletAddress, network, quoteResult.quoteId); return executeResult; } export { executeSwap }; - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Quote and execute a token swap on Jupiter in one step', - tags: ['/connector/jupiter'], - body: JupiterExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { - walletAddress, - network, - baseToken, - quoteToken, - amount, - side, - slippagePct, - priorityLevel, - maxLamports, - approximateIfNoExactOut, - } = request.body as typeof JupiterExecuteSwapRequest._type; - - return await executeSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - priorityLevel, - maxLamports, - approximateIfNoExactOut, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/jupiter/router-routes/index.ts b/src/connectors/jupiter/router-routes/index.ts deleted file mode 100644 index 0bf7c37769..0000000000 --- a/src/connectors/jupiter/router-routes/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import executeQuoteRoute from './executeQuote'; -import executeSwapRoute from './executeSwap'; -import quoteSwapRoute from './quoteSwap'; - -export const jupiterRouterRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(quoteSwapRoute); - await fastify.register(executeQuoteRoute); - await fastify.register(executeSwapRoute); -}; - -export default jupiterRouterRoutes; diff --git a/src/connectors/jupiter/router-routes/quoteSwap.ts b/src/connectors/jupiter/router-routes/quoteSwap.ts index a66809bf1e..ea89cc2680 100644 --- a/src/connectors/jupiter/router-routes/quoteSwap.ts +++ b/src/connectors/jupiter/router-routes/quoteSwap.ts @@ -1,18 +1,15 @@ import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapRequestType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { sanitizeErrorMessage, sanitizeString } from '../../../services/sanitize'; -import { approximateBuyViaSellLeg } from '../../router-utils'; +import { approximateBuyViaSellLeg, attemptedRoute, priceImpactPercentFromFraction } from '../../router-utils'; import { Jupiter } from '../jupiter'; import { JupiterConfig } from '../jupiter.config'; -import { JupiterQuoteSwapRequest, JupiterQuoteSwapResponse } from '../schemas'; - +import { JupiterQuoteSwapResponse } from '../schemas'; export async function quoteSwap( network: string, baseToken: string, @@ -20,8 +17,6 @@ export async function quoteSwap( amount: number, side: 'BUY' | 'SELL', slippagePct: number = JupiterConfig.config.slippagePct, - onlyDirectRoutes?: boolean, - restrictIntermediateTokens?: boolean, approximateIfNoExactOut: boolean = true, ): Promise> { const solana = await Solana.getInstance(network); @@ -43,9 +38,10 @@ export async function quoteSwap( logger.info(`Getting quote for ${amount} ${inputToken.symbol} -> ${outputToken.symbol}`); - const effectiveOnlyDirectRoutes = onlyDirectRoutes ?? JupiterConfig.config.onlyDirectRoutes; - const effectiveRestrictIntermediateTokens = - restrictIntermediateTokens ?? JupiterConfig.config.restrictIntermediateTokens; + // Routing policy comes from the connector config (conf/connectors/jupiter.yml), + // not per-request parameters. + const effectiveOnlyDirectRoutes = JupiterConfig.config.onlyDirectRoutes; + const effectiveRestrictIntermediateTokens = JupiterConfig.config.restrictIntermediateTokens; let quoteResponse; let approximation = false; @@ -98,15 +94,21 @@ export async function quoteSwap( quoteResponse = approximated.forwardQuote.quote; approximation = true; } catch (fallbackError) { - const tokenPair = `${sanitizeString(baseToken)} -> ${sanitizeString(quoteToken)}`; const msg = fallbackError?.message || String(fallbackError); - throw httpErrors.noRouteFound(`No route found for ${tokenPair} (ExactOut, ExactIn fallback failed). ${msg}`); + const route = attemptedRoute( + side, + sanitizeString(baseToken), + sanitizeString(quoteToken), + 'ExactOut, ExactIn fallback failed', + ); + throw httpErrors.noRouteFound(`No route found for ${route}. ${msg}`); } } else { - // Pass through Jupiter's error with context - const tokenPair = `${sanitizeString(baseToken)} -> ${sanitizeString(quoteToken)}`; - const swapMode = side === 'BUY' ? 'ExactOut' : 'ExactIn'; - throw httpErrors.noRouteFound(`No route found for ${tokenPair} (${swapMode}). ${errorMessage}`); + // Pass through Jupiter's error, naming the route that was actually attempted. This + // branch serves a failed SELL and a BUY that declined approximation, and the two + // are quoted in opposite directions and opposite modes. + const route = attemptedRoute(side, sanitizeString(baseToken), sanitizeString(quoteToken)); + throw httpErrors.noRouteFound(`No route found for ${route}. ${errorMessage}`); } } @@ -151,7 +153,7 @@ export async function quoteSwap( amountIn: side === 'SELL' ? amount : estimatedAmountIn, amountOut: outputIsExact ? amount : estimatedAmountOut, price, - priceImpactPct: parseFloat(quoteResponse.priceImpactPct || '0'), + priceImpactPct: priceImpactPercentFromFraction(quoteResponse.priceImpactPct), minAmountOut, maxAmountIn, approximation, @@ -164,6 +166,8 @@ export async function quoteSwap( otherAmountThreshold: quoteResponse.otherAmountThreshold || '0', swapMode: quoteResponse.swapMode || 'ExactIn', slippageBps: quoteResponse.slippageBps, + // Jupiter's own payload, handed back to Jupiter at execution: its fields stay in + // Jupiter's units. Only the unified field above is normalised to a percentage. priceImpactPct: quoteResponse.priceImpactPct || '0', routePlan: quoteResponse.routePlan || [], contextSlot: quoteResponse.contextSlot, @@ -171,53 +175,3 @@ export async function quoteSwap( }, }; } - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: Static; - }>( - '/quote-swap', - { - schema: { - description: 'Get an executable swap quote from Jupiter', - tags: ['/connector/jupiter'], - querystring: JupiterQuoteSwapRequest, - response: { 200: JupiterQuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { - network, - baseToken, - quoteToken, - amount, - side, - slippagePct, - onlyDirectRoutes, - restrictIntermediateTokens, - approximateIfNoExactOut, - } = request.query as typeof JupiterQuoteSwapRequest._type; - - return await quoteSwap( - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - onlyDirectRoutes, - restrictIntermediateTokens, - approximateIfNoExactOut, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error getting quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/connectors/jupiter/schemas.ts b/src/connectors/jupiter/schemas.ts index e454b2ece5..abbfe7a352 100644 --- a/src/connectors/jupiter/schemas.ts +++ b/src/connectors/jupiter/schemas.ts @@ -1,72 +1,8 @@ import { Type } from '@sinclair/typebox'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -import { JupiterConfig } from './jupiter.config'; - // Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); // Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.1; - -// Jupiter-specific quote-swap request (superset of base QuoteSwapRequest) -export const JupiterQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...JupiterConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: JupiterConfig.config.slippagePct, - }), - ), - restrictIntermediateTokens: Type.Optional( - Type.Boolean({ - description: 'Restrict routing through highly liquid intermediate tokens only for better price and stability', - default: JupiterConfig.config.restrictIntermediateTokens, - }), - ), - onlyDirectRoutes: Type.Optional( - Type.Boolean({ - description: 'Restrict routing to only go through 1 market', - default: JupiterConfig.config.onlyDirectRoutes, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders when the pair has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', - default: true, - }), - ), -}); // Jupiter-specific quote-swap response (superset of base QuoteSwapResponse) export const JupiterQuoteSwapResponse = Type.Object({ @@ -147,112 +83,3 @@ export const JupiterQuoteSwapResponse = Type.Object({ }), ), }); - -// Jupiter-specific execute-quote request (superset of base ExecuteQuoteRequest) -export const JupiterExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...JupiterConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the Jupiter quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), - priorityLevel: Type.Optional( - Type.String({ - description: 'Priority level for Solana transaction processing', - enum: ['medium', 'high', 'veryHigh'], - default: JupiterConfig.config.priorityLevel, - }), - ), - maxLamports: Type.Optional( - Type.Number({ - description: 'Maximum priority fee in lamports for Solana transaction', - default: [JupiterConfig.config.maxLamports], - }), - ), -}); - -// Jupiter-specific execute-swap request (superset of base ExecuteSwapRequest) -export const JupiterExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...JupiterConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: JupiterConfig.config.slippagePct, - }), - ), - restrictIntermediateTokens: Type.Optional( - Type.Boolean({ - description: 'Restrict routing through highly liquid intermediate tokens only for better price and stability', - default: JupiterConfig.config.restrictIntermediateTokens, - }), - ), - onlyDirectRoutes: Type.Optional( - Type.Boolean({ - description: 'Restrict routing to only go through 1 market', - default: JupiterConfig.config.onlyDirectRoutes, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders when the pair has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', - default: true, - }), - ), - priorityLevel: Type.Optional( - Type.String({ - description: 'Priority level for Solana transaction processing', - enum: ['medium', 'high', 'veryHigh'], - default: JupiterConfig.config.priorityLevel, - }), - ), - maxLamports: Type.Optional( - Type.Number({ - description: 'Maximum priority fee in lamports for Solana transaction', - default: JupiterConfig.config.maxLamports, - }), - ), -}); diff --git a/src/connectors/meteora/amm-routes/addLiquidity.ts b/src/connectors/meteora/amm-routes/addLiquidity.ts index 4decdb7f4d..9519706a0e 100644 --- a/src/connectors/meteora/amm-routes/addLiquidity.ts +++ b/src/connectors/meteora/amm-routes/addLiquidity.ts @@ -1,14 +1,13 @@ -import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; +import { PublicKey } from '@solana/web3.js'; import { Solana } from '../../../chains/solana/solana'; -import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas/amm-schema'; +import { AddLiquidityResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraAmmAddLiquidityRequest } from '../schemas'; +import { openPosition } from './openPosition'; import { getLiquidityQuote } from './quoteLiquidity'; export async function addLiquidity( @@ -20,6 +19,35 @@ export async function addLiquidity( slippagePct: number = MeteoraConfig.config.slippagePct, positionAddress?: string, ): Promise { + // Opening a new position is its own on-chain operation (it mints the position NFT + // and locks rent), so it lives in openPosition and is reachable directly through + // /trading/amm/open. Adding without a position address still opens one — we never + // silently pick an existing position — and passes its address and rent through, so + // the caller who just paid for it is told which position it is. + if (!positionAddress) { + const opened = await openPosition( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + return opened.data + ? { + signature: opened.signature, + status: opened.status, + data: { + fee: opened.data.fee, + positionAddress: opened.data.positionAddress, + positionRent: opened.data.positionRent, + baseTokenAmountAdded: opened.data.baseTokenAmountAdded, + quoteTokenAmountAdded: opened.data.quoteTokenAmountAdded, + }, + } + : { signature: opened.signature, status: opened.status }; + } + const solana = await Solana.getInstance(network); const meteoraDamm = await MeteoraDamm.getInstance(network); @@ -31,13 +59,23 @@ export async function addLiquidity( throw httpErrors.badRequest('Computed liquidity is zero — increase the token amounts'); } - const owner = new PublicKey(walletAddress); - const pool = new PublicKey(poolAddress); - - let transaction: Transaction; - const extraSigners: Keypair[] = []; + const existing = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + const target = existing.find((p) => p.position.toBase58() === positionAddress); + if (!target) { + throw httpErrors.notFound( + `Position ${positionAddress} not found for wallet in pool ${poolAddress}. ` + + 'List the wallet positions with position-info, or omit positionAddress to open a new position.', + ); + } - const shared = { + logger.info(`Adding liquidity to existing DAMM v2 position ${target.position.toBase58()} in pool ${poolAddress}`); + const transaction = await meteoraDamm.cpAmm.addLiquidity({ + owner: new PublicKey(walletAddress), + pool: new PublicKey(poolAddress), + position: target.position, + positionNftAccount: target.positionNftAccount, + tokenAVault: poolState.tokenAVault, + tokenBVault: poolState.tokenBVault, liquidityDelta: quote.liquidityDelta, maxAmountTokenA: quote.maxAmountTokenA, maxAmountTokenB: quote.maxAmountTokenB, @@ -47,48 +85,13 @@ export async function addLiquidity( tokenBMint: poolState.tokenBMint, tokenAProgram, tokenBProgram, - }; - - // DAMM v2 positions are NFTs; a wallet may hold several per pool. If a position address is given, - // add to that specific position (owner-filtered lookup also proves ownership + pool membership). - // If omitted, open a NEW position NFT — we never silently pick an existing one. - if (positionAddress) { - const existing = await meteoraDamm.getUserPositions(poolAddress, walletAddress); - const target = existing.find((p) => p.position.toBase58() === positionAddress); - if (!target) { - throw httpErrors.notFound( - `Position ${positionAddress} not found for wallet in pool ${poolAddress}. ` + - 'List the wallet positions with position-info, or omit positionAddress to open a new position.', - ); - } - logger.info(`Adding liquidity to existing DAMM v2 position ${target.position.toBase58()} in pool ${poolAddress}`); - transaction = await meteoraDamm.cpAmm.addLiquidity({ - owner, - pool, - position: target.position, - positionNftAccount: target.positionNftAccount, - tokenAVault: poolState.tokenAVault, - tokenBVault: poolState.tokenBVault, - ...shared, - }); - } else { - const positionNft = Keypair.generate(); - extraSigners.push(positionNft); - logger.info(`Opening new DAMM v2 position (NFT ${positionNft.publicKey.toBase58()}) in pool ${poolAddress}`); - transaction = await meteoraDamm.cpAmm.createPositionAndAddLiquidity({ - owner, - pool, - positionNft: positionNft.publicKey, - ...shared, - }); - } - - const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, extraSigners); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, }); + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); + if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ poolState.tokenAMint.toBase58(), @@ -99,6 +102,10 @@ export async function addLiquidity( status: 1, // CONFIRMED data: { fee: txData.meta.fee / 1e9, + // Echoed so the field always names the position the write touched, whether it + // was opened by this call or named by the caller. No rent: the account already + // existed, so this add locked none. + positionAddress: target.position.toBase58(), baseTokenAmountAdded: Math.abs(balanceChanges[0]), quoteTokenAmountAdded: Math.abs(balanceChanges[1]), }, @@ -106,46 +113,3 @@ export async function addLiquidity( } return { signature, status: 0 }; // PENDING } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: typeof MeteoraAmmAddLiquidityRequest.static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: - 'Add liquidity to a Meteora DAMM v2 pool. Provide positionAddress to add to a specific ' + - 'position (NFT); omit it to open a new position.', - tags: ['/connector/meteora'], - body: MeteoraAmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct, positionAddress } = - request.body; - const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; - return await addLiquidity( - network, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - effectiveSlippage, - positionAddress, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to add liquidity'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/meteora/amm-routes/closePosition.ts b/src/connectors/meteora/amm-routes/closePosition.ts new file mode 100644 index 0000000000..96fd4d908b --- /dev/null +++ b/src/connectors/meteora/amm-routes/closePosition.ts @@ -0,0 +1,130 @@ +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Decimal } from 'decimal.js'; + +import { Solana } from '../../../chains/solana/solana'; +import { accountLifecycleSol, liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraConfig } from '../meteora.config'; + +function withSlippageDown(raw: BN, slippagePct: number): BN { + return new BN(new Decimal(raw.toString()).mul(1 - slippagePct / 100).toFixed(0)); +} + +/** + * Withdraw all of a DAMM v2 position's liquidity and close the position account + * itself, which returns the account's rent to the wallet. + * + * Not a route of its own. It is what removeLiquidity does at 100%, because removing + * all the liquidity without this leaves an empty position NFT behind still holding + * its rent, and nothing later reclaims it. The SDK's + * removeAllLiquidityAndClosePosition does both in one transaction. + */ +export async function closePosition( + network: string, + walletAddress: string, + poolAddress: string, + positionAddress: string, + slippagePct: number = MeteoraConfig.config.slippagePct, +): Promise { + const solana = await Solana.getInstance(network); + const meteoraDamm = await MeteoraDamm.getInstance(network); + + const poolState = await meteoraDamm.getPoolState(poolAddress); + + // getUserPositions is owner-filtered, so finding the position here also proves the + // wallet owns it and that it belongs to this pool. + const positions = await meteoraDamm.getUserPositions(poolAddress, walletAddress); + const target = positions.find((p) => p.position.toBase58() === positionAddress); + if (!target) { + throw httpErrors.notFound( + `Position ${positionAddress} not found for wallet in pool ${poolAddress}. ` + + 'List the wallet positions with position-info.', + ); + } + + const unlocked = target.positionState.unlockedLiquidity; + // A position whose liquidity is still vesting cannot be closed: the program keeps + // the account alive until the lock expires, so report that instead of failing on-chain. + if (target.positionState.vestedLiquidity && !target.positionState.vestedLiquidity.isZero()) { + throw httpErrors.badRequest( + `Position ${positionAddress} still holds vested (locked) liquidity and cannot be closed yet. ` + + 'Remove the unlocked portion with remove, and close once the vesting completes.', + ); + } + + const withdrawQuote = meteoraDamm.cpAmm.getWithdrawQuote({ + liquidityDelta: unlocked, + minSqrtPrice: poolState.sqrtMinPrice, + maxSqrtPrice: poolState.sqrtMaxPrice, + sqrtPrice: poolState.sqrtPrice, + collectFeeMode: poolState.collectFeeMode, + tokenAAmount: poolState.tokenAAmount, + tokenBAmount: poolState.tokenBAmount, + liquidity: poolState.liquidity, + }); + + const vestings = (await meteoraDamm.cpAmm.getAllVestingsByPosition(target.position)).map((v) => ({ + account: v.publicKey, + vestingState: v.account, + })); + + const slot = await solana.connection.getSlot(); + const time = await solana.connection.getBlockTime(slot); + const currentPoint = meteoraDamm.getCurrentPoint(poolState, slot, time ?? Math.floor(Date.now() / 1000)); + + logger.info(`Closing DAMM v2 position ${target.position.toBase58()} in pool ${poolAddress}`); + + const transaction = await meteoraDamm.cpAmm.removeAllLiquidityAndClosePosition({ + owner: new PublicKey(walletAddress), + position: target.position, + positionNftAccount: target.positionNftAccount, + poolState, + positionState: target.positionState, + tokenAAmountThreshold: withSlippageDown(withdrawQuote.outAmountA, slippagePct), + tokenBAmountThreshold: withSlippageDown(withdrawQuote.outAmountB, slippagePct), + vestings, + currentPoint, + }); + + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); + + if (!txData) { + return { signature, status: 0 }; // PENDING + } + + // A DAMM v2 position is three rent-bearing accounts, not one: the position, the NFT + // that represents it, and that NFT's token account. All three close here and all three + // refund to the wallet, so reading the position's own balance alone — which is what + // this did — captured 38% of the refund and left the rest inside the withdrawal. + const { closed, rentRefunded } = accountLifecycleSol(txData); + + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + poolState.tokenAMint.toBase58(), + poolState.tokenBMint.toBase58(), + ]); + + const fee = txData.meta.fee / 1e9; + // The native side of the change carries every lamport those accounts gave back; take + // all of it out to leave the liquidity actually withdrawn. `closed` rather than + // `rentRefunded` because a wrapped-SOL account closing here also returns a balance the + // wallet already held, which is not this position's money either. The transaction fee + // needs no correction: extractBalanceChangesAndFee already adds it back for the fee + // payer and reports it separately as `fee`. + return { + signature, + status: 1, // CONFIRMED + data: { + fee, + positionRentRefunded: rentRefunded, + baseTokenAmountRemoved: liquidityWithoutRent(balanceChanges[0], poolState.tokenAMint, closed), + quoteTokenAmountRemoved: liquidityWithoutRent(balanceChanges[1], poolState.tokenBMint, closed), + }, + }; +} diff --git a/src/connectors/meteora/amm-routes/createPool.ts b/src/connectors/meteora/amm-routes/createPool.ts index abe36ca9bf..789cf805cc 100644 --- a/src/connectors/meteora/amm-routes/createPool.ts +++ b/src/connectors/meteora/amm-routes/createPool.ts @@ -3,15 +3,13 @@ import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { MeteoraDamm } from '../meteora-damm'; -import { MeteoraAmmCreatePoolRequest } from '../schemas'; /** Resolves a token symbol or mint address to a PublicKey. */ async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { @@ -55,10 +53,10 @@ async function fetchMarketPrice( seedAmount: number, ): Promise { const probeAmount = seedAmount * MARKET_PRICE_PROBE_FRACTION; - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, probeAmount, 'SELL'); + quote = await getSwapQuote(`solana-${network}`, baseToken, quoteToken, probeAmount, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + @@ -196,10 +194,9 @@ export async function createPool( }); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, [positionNft]); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ @@ -220,52 +217,3 @@ export async function createPool( } return { signature, status: 0, poolAddress: pool.toBase58(), price: seedPrice }; // PENDING } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: typeof MeteoraAmmCreatePoolRequest.static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: 'Create a new Meteora DAMM v2 pool and seed it with initial liquidity', - tags: ['/connector/meteora'], - body: MeteoraAmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - configAddress, - initialPrice, - } = request.body; - return await createPool( - network, - walletAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - configAddress, - initialPrice, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/meteora/amm-routes/executeSwap.ts b/src/connectors/meteora/amm-routes/executeSwap.ts index 8e12fe4031..80c52aca83 100644 --- a/src/connectors/meteora/amm-routes/executeSwap.ts +++ b/src/connectors/meteora/amm-routes/executeSwap.ts @@ -1,14 +1,11 @@ import { SwapMode } from '@meteora-ag/cp-amm-sdk'; import { PublicKey, Transaction } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ExecuteSwapResponse, ExecuteSwapResponseType } from '../../../schemas/amm-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { ExecuteSwapResponseType } from '../../../schemas/amm-schema'; import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraAmmExecuteSwapRequest } from '../schemas'; import { getRawSwapQuote } from './quoteSwap'; @@ -61,61 +58,19 @@ export async function executeSwap( }); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, quote.inputMint.toBase58(), quote.outputMint.toBase58(), walletAddress, side, + slippagePct, ); return result as ExecuteSwapResponseType; } - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: typeof MeteoraAmmExecuteSwapRequest.static; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on a Meteora DAMM v2 pool', - tags: ['/connector/meteora'], - body: MeteoraAmmExecuteSwapRequest, - response: { - 200: ExecuteSwapResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, poolAddress, baseToken, amount, side, slippagePct } = request.body; - const effectiveSlippage = slippagePct ?? MeteoraConfig.config.slippagePct; - - return await executeSwap( - network, - walletAddress, - poolAddress, - baseToken, - side as 'BUY' | 'SELL', - amount, - effectiveSlippage, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Swap execution failed'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/meteora/amm-routes/index.ts b/src/connectors/meteora/amm-routes/index.ts deleted file mode 100644 index 9b93ee334d..0000000000 --- a/src/connectors/meteora/amm-routes/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { addLiquidityRoute } from './addLiquidity'; -import { createPoolRoute } from './createPool'; -import { executeSwapRoute } from './executeSwap'; -import { poolInfoRoute } from './poolInfo'; -import { positionInfoRoute } from './positionInfo'; -import { positionsOwnedRoute } from './positionsOwned'; -import { quoteLiquidityRoute } from './quoteLiquidity'; -import { quoteSwapRoute } from './quoteSwap'; -import { removeLiquidityRoute } from './removeLiquidity'; - -export const meteoraAmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionInfoRoute); - await fastify.register(positionsOwnedRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(quoteLiquidityRoute); - await fastify.register(executeSwapRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(createPoolRoute); -}; - -export default meteoraAmmRoutes; diff --git a/src/connectors/meteora/amm-routes/openPosition.ts b/src/connectors/meteora/amm-routes/openPosition.ts new file mode 100644 index 0000000000..49d2746f0a --- /dev/null +++ b/src/connectors/meteora/amm-routes/openPosition.ts @@ -0,0 +1,96 @@ +import { derivePositionAddress } from '@meteora-ag/cp-amm-sdk'; +import { Keypair, PublicKey } from '@solana/web3.js'; + +import { Solana } from '../../../chains/solana/solana'; +import { accountLifecycleSol, liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { AddLiquidityResponseType } from '../../../schemas/amm-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { MeteoraDamm } from '../meteora-damm'; +import { MeteoraConfig } from '../meteora.config'; + +import { getLiquidityQuote } from './quoteLiquidity'; + +/** + * Open a NEW DAMM v2 position and seed it with liquidity. + * + * DAMM v2 positions are NFTs, so opening one is a distinct on-chain operation + * (`createPositionAndAddLiquidity`) rather than a variant of adding: it mints the + * position NFT and locks rent for the account. Adding to a position that already + * exists goes through addLiquidity with its address. + */ +export async function openPosition( + network: string, + walletAddress: string, + poolAddress: string, + baseTokenAmount: number, + quoteTokenAmount: number, + slippagePct: number = MeteoraConfig.config.slippagePct, +): Promise { + const solana = await Solana.getInstance(network); + const meteoraDamm = await MeteoraDamm.getInstance(network); + + const poolState = await meteoraDamm.getPoolState(poolAddress); + const { tokenAProgram, tokenBProgram } = meteoraDamm.getTokenPrograms(poolState); + + const quote = await getLiquidityQuote(meteoraDamm, poolState, baseTokenAmount, quoteTokenAmount, slippagePct); + if (quote.liquidityDelta.isZero()) { + throw httpErrors.badRequest('Computed liquidity is zero — increase the token amounts'); + } + + const positionNft = Keypair.generate(); + logger.info(`Opening new DAMM v2 position (NFT ${positionNft.publicKey.toBase58()}) in pool ${poolAddress}`); + + const transaction = await meteoraDamm.cpAmm.createPositionAndAddLiquidity({ + owner: new PublicKey(walletAddress), + pool: new PublicKey(poolAddress), + positionNft: positionNft.publicKey, + liquidityDelta: quote.liquidityDelta, + maxAmountTokenA: quote.maxAmountTokenA, + maxAmountTokenB: quote.maxAmountTokenB, + tokenAAmountThreshold: quote.maxAmountTokenA, + tokenBAmountThreshold: quote.maxAmountTokenB, + tokenAMint: poolState.tokenAMint, + tokenBMint: poolState.tokenBMint, + tokenAProgram, + tokenBProgram, + }); + + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, [positionNft]); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); + + if (!txData) { + // Submitted but not yet confirmed. The position address is only knowable from + // the landed transaction, so it is reported once the poller sees it confirm. + return { signature, status: 0 }; + } + + // Opening locks rent in three accounts — the position, the NFT mint that represents + // it, and that mint's token account — and the wallet pays for all of them. Reading the + // position's own balance alone left the other two inside the reported deposit. + const position = derivePositionAddress(positionNft.publicKey); + const { opened, rentLocked } = accountLifecycleSol(txData); + + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + poolState.tokenAMint.toBase58(), + poolState.tokenBMint.toBase58(), + ]); + + // The native side of the change carries every lamport those accounts locked; take all + // of it out to leave the liquidity actually deposited. The transaction fee needs no + // correction — extractBalanceChangesAndFee already adds it back for the fee payer and + // reports it separately as `fee`. + return { + signature, + status: 1, // CONFIRMED + data: { + fee: txData.meta.fee / 1e9, + positionAddress: position.toBase58(), + positionRent: rentLocked, + baseTokenAmountAdded: liquidityWithoutRent(balanceChanges[0], poolState.tokenAMint, opened), + quoteTokenAmountAdded: liquidityWithoutRent(balanceChanges[1], poolState.tokenBMint, opened), + }, + }; +} diff --git a/src/connectors/meteora/amm-routes/poolInfo.ts b/src/connectors/meteora/amm-routes/poolInfo.ts index b03e3b2885..e531dac7b9 100644 --- a/src/connectors/meteora/amm-routes/poolInfo.ts +++ b/src/connectors/meteora/amm-routes/poolInfo.ts @@ -1,43 +1,8 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; -import { logger } from '../../../services/logger'; +import { PoolInfo } from '../../../schemas/amm-schema'; import { MeteoraDamm } from '../meteora-damm'; -import { MeteoraAmmGetPoolInfoRequest } from '../schemas'; /** Standard AMM pool-info entry point (network-based) — consumed by the unified /trading/amm dispatcher. */ export async function getPoolInfo(network: string, poolAddress: string): Promise { const meteoraDamm = await MeteoraDamm.getInstance(network); return await meteoraDamm.getPoolInfo(poolAddress); } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPoolInfoRequestType; - Reply: PoolInfo; - }>( - '/pool-info', - { - schema: { - description: 'Get AMM pool information from Meteora DAMM v2', - tags: ['/connector/meteora'], - querystring: MeteoraAmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress, network } = request.query; - return await getPoolInfo(network, poolAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/meteora/amm-routes/positionInfo.ts b/src/connectors/meteora/amm-routes/positionInfo.ts index a7a7fae47d..2fb648e13b 100644 --- a/src/connectors/meteora/amm-routes/positionInfo.ts +++ b/src/connectors/meteora/amm-routes/positionInfo.ts @@ -2,18 +2,10 @@ import { q64ToDecimal } from '@meteora-ag/cp-amm-sdk'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; -import { - GetPositionInfoRequestType, - PositionInfo, - PositionInfoSchema, - PositionDetail, -} from '../../../schemas/amm-schema'; +import { PositionInfo, PositionDetail } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; -import { MeteoraAmmGetPositionInfoRequest } from '../schemas'; /** * Standard AMM position-info entry point (network-based) — consumed by the unified /trading/amm @@ -82,36 +74,3 @@ export async function getPositionInfo( positions: breakdown, }; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: - "Get the wallet's aggregated liquidity in a Meteora DAMM v2 pool. DAMM v2 positions " + - 'are NFTs; amounts sum across all of the wallet positions in the pool.', - tags: ['/connector/meteora'], - querystring: MeteoraAmmGetPositionInfoRequest, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress, walletAddress, network } = request.query; - return await getPositionInfo(network, poolAddress, walletAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to fetch position info'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/meteora/amm-routes/positionsOwned.ts b/src/connectors/meteora/amm-routes/positionsOwned.ts index 4be02a47ed..3c16b89477 100644 --- a/src/connectors/meteora/amm-routes/positionsOwned.ts +++ b/src/connectors/meteora/amm-routes/positionsOwned.ts @@ -1,11 +1,9 @@ -import { Type } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { PositionInfo, PositionInfoSchema } from '../../../schemas/amm-schema'; +import { PositionInfo } from '../../../schemas/amm-schema'; import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; -import { MeteoraAmmGetPositionsOwnedRequest, MeteoraAmmGetPositionsOwnedRequestType } from '../schemas'; import { getPositionInfo } from './positionInfo'; @@ -41,34 +39,3 @@ export async function getPositionsOwned( } return result; } - -export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: MeteoraAmmGetPositionsOwnedRequestType; - Reply: PositionInfo[]; - }>( - '/positions-owned', - { - schema: { - description: "List all of a wallet's DAMM v2 positions across all Meteora AMM pools", - tags: ['/connector/meteora'], - querystring: MeteoraAmmGetPositionsOwnedRequest, - response: { - 200: Type.Array(PositionInfoSchema), - }, - }, - }, - async (request) => { - try { - const { network, walletAddress } = request.query; - return await getPositionsOwned(fastify, network, walletAddress); - } catch (e: any) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to fetch positions'); - } - }, - ); -}; - -export default positionsOwnedRoute; diff --git a/src/connectors/meteora/amm-routes/quoteLiquidity.ts b/src/connectors/meteora/amm-routes/quoteLiquidity.ts index 78544d13e0..1b3e7e661f 100644 --- a/src/connectors/meteora/amm-routes/quoteLiquidity.ts +++ b/src/connectors/meteora/amm-routes/quoteLiquidity.ts @@ -1,17 +1,10 @@ import { PoolState } from '@meteora-ag/cp-amm-sdk'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; -import { - QuoteLiquidityRequestType, - QuoteLiquidityResponse, - QuoteLiquidityResponseType, -} from '../../../schemas/amm-schema'; -import { logger } from '../../../services/logger'; +import { QuoteLiquidityResponseType } from '../../../schemas/amm-schema'; import { MeteoraDamm } from '../meteora-damm'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraAmmQuoteLiquidityRequest } from '../schemas'; /** A resolved deposit quote: which side limits the deposit, the amounts, and the liquidity delta. */ export interface LiquidityQuote { @@ -128,34 +121,3 @@ export async function quoteLiquidity( quoteTokenAmountMax: quote.quoteTokenAmountMax, }; } - -export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteLiquidityRequestType; - Reply: QuoteLiquidityResponseType; - }>( - '/quote-liquidity', - { - schema: { - description: 'Quote amounts for adding liquidity to a Meteora DAMM v2 pool', - tags: ['/connector/meteora'], - querystring: MeteoraAmmQuoteLiquidityRequest, - response: { - 200: QuoteLiquidityResponse, - }, - }, - }, - async (request): Promise => { - try { - const { network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; - return await quoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to quote liquidity'); - } - }, - ); -}; - -export default quoteLiquidityRoute; diff --git a/src/connectors/meteora/amm-routes/quoteSwap.ts b/src/connectors/meteora/amm-routes/quoteSwap.ts index 02def03bb2..e150eed556 100644 --- a/src/connectors/meteora/amm-routes/quoteSwap.ts +++ b/src/connectors/meteora/amm-routes/quoteSwap.ts @@ -2,16 +2,13 @@ import { PoolState, SwapMode } from '@meteora-ag/cp-amm-sdk'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapResponse, QuoteSwapResponseType } from '../../../schemas/amm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { MeteoraDamm } from '../meteora-damm'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraAmmQuoteSwapRequest } from '../schemas'; /** * A fully-resolved DAMM v2 swap quote. The BN fields are what the swap instruction consumes; @@ -182,34 +179,3 @@ export async function quoteSwap( priceImpactPct: quote.priceImpactPct, }; } - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: typeof MeteoraAmmQuoteSwapRequest.static; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get a swap quote for a Meteora DAMM v2 pool', - tags: ['/connector/meteora'], - querystring: MeteoraAmmQuoteSwapRequest, - response: { - 200: QuoteSwapResponse, - }, - }, - }, - async (request): Promise => { - try { - const { network, poolAddress, baseToken, amount, side, slippagePct } = request.query; - return await quoteSwap(network, poolAddress, baseToken, side as 'BUY' | 'SELL', amount, slippagePct); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to get swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/connectors/meteora/amm-routes/removeLiquidity.ts b/src/connectors/meteora/amm-routes/removeLiquidity.ts index 6a8f15839b..c50cf82f1f 100644 --- a/src/connectors/meteora/amm-routes/removeLiquidity.ts +++ b/src/connectors/meteora/amm-routes/removeLiquidity.ts @@ -1,15 +1,15 @@ import { PublicKey, Transaction } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { MeteoraDamm } from '../meteora-damm'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraAmmRemoveLiquidityRequest } from '../schemas'; + +import { closePosition } from './closePosition'; function withSlippageDown(raw: BN, slippagePct: number): BN { return new BN(new Decimal(raw.toString()).mul(1 - slippagePct / 100).toFixed(0)); @@ -27,6 +27,15 @@ export async function removeLiquidity( throw httpErrors.badRequest('percentageToRemove must be between 0 and 100'); } + // Removing everything closes the position account with it. Withdrawing the last of a + // position's liquidity and stopping there leaves an empty NFT behind still holding its + // rent — around 0.0099 SOL, which on a small position is more than the liquidity — and + // no later call reclaims it. The SDK does both in one transaction, so a caller asking + // for 100% gets the rent back rather than having to know to ask for it separately. + if (percentageToRemove === 100) { + return await closePosition(network, walletAddress, poolAddress, positionAddress, slippagePct); + } + const solana = await Solana.getInstance(network); const meteoraDamm = await MeteoraDamm.getInstance(network); @@ -96,10 +105,9 @@ export async function removeLiquidity( }); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ @@ -118,41 +126,3 @@ export async function removeLiquidity( } return { signature, status: 0 }; // PENDING } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: typeof MeteoraAmmRemoveLiquidityRequest.static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a specific position (NFT) in a Meteora DAMM v2 pool', - tags: ['/connector/meteora'], - body: MeteoraAmmRemoveLiquidityRequest, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, poolAddress, positionAddress, percentageToRemove } = request.body; - return await removeLiquidity( - network, - walletAddress, - poolAddress, - positionAddress, - percentageToRemove, - MeteoraConfig.config.slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to remove liquidity'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/meteora/clmm-routes/addLiquidity.ts b/src/connectors/meteora/clmm-routes/addLiquidity.ts index dd8f1061ed..202574fc53 100644 --- a/src/connectors/meteora/clmm-routes/addLiquidity.ts +++ b/src/connectors/meteora/clmm-routes/addLiquidity.ts @@ -1,23 +1,19 @@ import { StrategyType } from '@meteora-ag/dlmm'; import { DecimalUtil } from '@orca-so/common-sdk'; -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; import { BN } from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas/clmm-schema'; +import { AddLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraClmmAddLiquidityRequest } from '../schemas'; // Using Fastify's native error handling // Define error messages -const INVALID_SOLANA_ADDRESS_MESSAGE = (address: string) => `Invalid Solana address: ${address}`; const MISSING_AMOUNTS_MESSAGE = 'Missing amounts for liquidity addition'; const INSUFFICIENT_BALANCE_MESSAGE = (token: string, required: string, actual: string) => `Insufficient balance for ${token}. Required: ${required}, Available: ${actual}`; @@ -123,10 +119,9 @@ export async function addLiquidity( const { signature, fee } = await solana.sendAndConfirmTransactionForWallet(addLiquidityTx, address); // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -157,6 +152,10 @@ export async function addLiquidity( signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: info.publicKey.toBase58(), baseTokenAmountAdded: tokenXAddedAmount, quoteTokenAmountAdded: tokenYAddedAmount, fee, @@ -169,49 +168,3 @@ export async function addLiquidity( }; } } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to a Meteora position', - tags: ['/connector/meteora'], - body: MeteoraClmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { walletAddress, positionAddress, baseTokenAmount, quoteTokenAmount, slippagePct, strategyType } = - request.body; - const network = request.body.network; - - return await addLiquidity( - network, - walletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - strategyType, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/meteora/clmm-routes/closePosition.ts b/src/connectors/meteora/clmm-routes/closePosition.ts index 8351b56844..545ee033fd 100644 --- a/src/connectors/meteora/clmm-routes/closePosition.ts +++ b/src/connectors/meteora/clmm-routes/closePosition.ts @@ -1,14 +1,11 @@ import { BN } from '@coral-xyz/anchor'; -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ClosePositionResponse, ClosePositionResponseType } from '../../../schemas/clmm-schema'; +import { ClosePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; -import { MeteoraClmmClosePositionRequest } from '../schemas'; export async function closePosition( network: string, @@ -84,10 +81,9 @@ export async function closePosition( const signature = lastSignature; // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -117,15 +113,16 @@ export async function closePosition( let totalTokenXReceived = Math.abs(balanceChanges[0]); let totalTokenYReceived = Math.abs(balanceChanges[1]); - // When SOL is base/quote, wallet balance change includes: liquidity + fees + rent refund - tx fee - // We need to subtract rent refund to get actual token amounts + // When SOL is base/quote, the wallet's change for that side is liquidity + fees + // collected + the rent refund, so back the rent out to leave what the position + // actually returned. The transaction fee needs no correction: extractBalanceChangesAndFee + // nets it out for the fee payer and reports it separately, so adding it back here + // would overstate the amount by one fee. if (tokenXSymbol === 'SOL') { - // SOL is base token - subtract rent refund and add back tx fee - totalTokenXReceived = totalTokenXReceived - positionRentRefunded + totalFee; + totalTokenXReceived = totalTokenXReceived - positionRentRefunded; if (totalTokenXReceived < 0) totalTokenXReceived = 0; } else if (tokenYSymbol === 'SOL') { - // SOL is quote token - subtract rent refund and add back tx fee - totalTokenYReceived = totalTokenYReceived - positionRentRefunded + totalFee; + totalTokenYReceived = totalTokenYReceived - positionRentRefunded; if (totalTokenYReceived < 0) totalTokenYReceived = 0; } @@ -142,6 +139,10 @@ export async function closePosition( signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: info.publicKey.toBase58(), fee: totalFee, positionRentRefunded: positionRentRefunded, baseTokenAmountRemoved, @@ -169,50 +170,3 @@ export async function closePosition( throw error; } } - -export const closePositionRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: ClosePositionResponseType; - }>( - '/close-position', - { - schema: { - description: 'Close a Meteora position', - tags: ['/connector/meteora'], - body: MeteoraClmmClosePositionRequest, - response: { - 200: ClosePositionResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, positionAddress } = request.body; - const networkToUse = network; - - return await closePosition(networkToUse, walletAddress, positionAddress); - } catch (e) { - logger.error('Close position route error:', { - message: e.message || 'Unknown error', - name: e.name, - code: e.code, - statusCode: e.statusCode, - stack: e.stack, - positionAddress: request.body.positionAddress, - network: request.body.network, - walletAddress: request.body.walletAddress, - }); - - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default closePositionRoute; diff --git a/src/connectors/meteora/clmm-routes/collectFees.ts b/src/connectors/meteora/clmm-routes/collectFees.ts index 9f1d347161..fa2dc1ab3d 100644 --- a/src/connectors/meteora/clmm-routes/collectFees.ts +++ b/src/connectors/meteora/clmm-routes/collectFees.ts @@ -1,13 +1,10 @@ -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CollectFeesResponse, CollectFeesRequestType, CollectFeesResponseType } from '../../../schemas/clmm-schema'; +import { CollectFeesResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; -import { MeteoraClmmCollectFeesRequest } from '../schemas'; export async function collectFees( network: string, @@ -72,10 +69,9 @@ export async function collectFees( const fee = totalFee; // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -96,6 +92,10 @@ export async function collectFees( signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: info.publicKey.toBase58(), fee, baseFeeAmountCollected: Math.abs(collectedFeeX), quoteFeeAmountCollected: Math.abs(collectedFeeY), @@ -108,40 +108,3 @@ export async function collectFees( }; } } - -export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: CollectFeesResponseType; - }>( - '/collect-fees', - { - schema: { - description: 'Collect fees from a Meteora position', - tags: ['/connector/meteora'], - body: MeteoraClmmCollectFeesRequest, - response: { - 200: CollectFeesResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, positionAddress } = request.body; - const networkToUse = network; - - return await collectFees(networkToUse, walletAddress, positionAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default collectFeesRoute; diff --git a/src/connectors/meteora/clmm-routes/createPool.ts b/src/connectors/meteora/clmm-routes/createPool.ts index c301d9bc46..afe4ccb810 100644 --- a/src/connectors/meteora/clmm-routes/createPool.ts +++ b/src/connectors/meteora/clmm-routes/createPool.ts @@ -6,14 +6,12 @@ import DLMM, { } from '@meteora-ag/dlmm'; import { PublicKey, Transaction } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; -import { MeteoraClmmCreatePoolRequest } from '../schemas'; // A DLMM pool is created with no liquidity; the initial active bin only encodes the starting // price. binStep/feeBps have no universal default, so both are required request params. @@ -36,10 +34,10 @@ async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + quote = await getSwapQuote(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + @@ -195,10 +193,9 @@ export async function createPool( } const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { return { @@ -208,42 +205,8 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: typeof MeteoraClmmCreatePoolRequest.static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: - 'Create and initialize a new Meteora DLMM pool (LB pair) at an initial price (no liquidity seeded)', - tags: ['/connector/meteora'], - body: MeteoraClmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, baseToken, quoteToken, initialPrice, binStep, feeBps } = request.body; - return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, binStep, feeBps); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/meteora/clmm-routes/executeSwap.ts b/src/connectors/meteora/clmm-routes/executeSwap.ts index b35f741ef9..3b3ab2637f 100644 --- a/src/connectors/meteora/clmm-routes/executeSwap.ts +++ b/src/connectors/meteora/clmm-routes/executeSwap.ts @@ -1,15 +1,10 @@ import { SwapQuoteExactOut, SwapQuote } from '@meteora-ag/dlmm'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; -import { ExecuteSwapResponseType, ExecuteSwapResponse } from '../../../schemas/clmm-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { ExecuteSwapResponseType } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; -import { sanitizeErrorMessage } from '../../../services/sanitize'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraClmmExecuteSwapRequest, MeteoraClmmExecuteSwapRequestType } from '../schemas'; import { resolveCounterToken, getRawSwapQuote } from './quoteSwap'; @@ -91,11 +86,10 @@ export async function executeSwap( logger.info(`Transaction sent with signature: ${signature}`); - // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Get transaction data for confirmation. The retrying fetch throws the shared + // landed-but-failed error when the transaction landed with an error, so existence of + // txData below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -135,6 +129,7 @@ export async function executeSwap( fee: txFee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } else { @@ -145,95 +140,3 @@ export async function executeSwap( }; } } - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: MeteoraClmmExecuteSwapRequestType; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a token swap on Meteora DLMM', - tags: ['/connector/meteora'], - body: MeteoraClmmExecuteSwapRequest, - response: { 200: ExecuteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, walletAddress, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = request.body; - - // Use defaults if not provided - const networkUsed = network || getSolanaChainConfig().defaultNetwork; - const walletAddressUsed = walletAddress || getSolanaChainConfig().defaultWallet; - - let poolAddressUsed = poolAddress; - - // If poolAddress is not provided, look it up by token pair - if (!poolAddressUsed) { - const solana = await Solana.getInstance(networkUsed); - - // Resolve token symbols to get proper symbols for pool lookup - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest( - sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken), - ); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'meteora', - networkUsed, - 'clmm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Meteora`, - ); - } - - poolAddressUsed = pool.address; - } - logger.info(`Received swap request: ${amount} ${baseToken} -> ${quoteToken} in pool ${poolAddressUsed}`); - - return await executeSwap( - networkUsed, - walletAddressUsed, - poolAddressUsed, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - } catch (e: any) { - logger.error('Error executing swap:', e.message || e); - logger.error('Full error:', JSON.stringify(e, null, 2)); - - if (e.statusCode) { - // If it's already an HTTP error, throw it properly - throw e; - } - - // Check for specific error messages - const errorMessage = e.message || e.toString(); - if (errorMessage.includes('503') || errorMessage.includes('Service Unavailable')) { - throw httpErrors.createError(503, 'RPC service temporarily unavailable. Please try again.'); - } - - throw httpErrors.internalServerError(`Swap execution failed: ${errorMessage}`); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/meteora/clmm-routes/fetchPools.ts b/src/connectors/meteora/clmm-routes/fetchPools.ts index fc8027fb61..448e30d08c 100644 --- a/src/connectors/meteora/clmm-routes/fetchPools.ts +++ b/src/connectors/meteora/clmm-routes/fetchPools.ts @@ -1,74 +1,55 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { FetchPoolsResponse } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { FetchPoolsResponseType } from '../../../schemas/clmm-schema'; import { Meteora, MeteoraApiPool } from '../meteora'; -import { MeteoraClmmFetchPoolsRequest } from '../schemas'; - -export const fetchPoolsRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: { - network?: string; - page?: number; - limit?: number; - query?: string; - sortBy?: string; - includeUnverified?: boolean; - }; - }>('/fetch-pools', { - schema: { - description: 'Fetch Meteora pools from API with search and sorting', - tags: ['/connector/meteora'], - querystring: MeteoraClmmFetchPoolsRequest, - response: { - 200: FetchPoolsResponse, - }, - }, - handler: async (request, _reply) => { - try { - const { network, page, limit, query, sortBy, includeUnverified } = request.query; - const meteora = await Meteora.getInstance(network); +export interface MeteoraFetchPoolsArgs { + network: string; + limit?: number; + query?: string; + sortBy?: string; + /** 0-based page index. Meteora's API paginates; Orca's does not. */ + page?: number; + includeUnverified?: boolean; +} - const result = await meteora.fetchPoolsFromApi({ - page, - limit, - query, - sortBy, - includeUnverified, - }); +/** + * Fetch pools from Meteora's own pool-discovery API and normalize them into the + * shared FetchPoolsResponse shape. Reached through GET /trading/clmm/fetch-pools. + */ +export async function fetchPools(args: MeteoraFetchPoolsArgs): Promise { + const { network, page, limit, query, sortBy, includeUnverified } = args; - // Map API response to simplified format - const pools = result.pools.map((pool: MeteoraApiPool) => ({ - address: pool.address, - name: pool.name, - baseTokenAddress: pool.token_x.address, - baseTokenSymbol: pool.token_x.symbol, - quoteTokenAddress: pool.token_y.address, - quoteTokenSymbol: pool.token_y.symbol, - binStep: pool.pool_config.bin_step, - baseFee: pool.pool_config.base_fee_pct, - price: pool.current_price, - tvl: pool.tvl, - apr: pool.apr, - apy: pool.apy, - volume24h: pool.volume?.['24h'], - fees24h: pool.fees?.['24h'], - })); + const meteora = await Meteora.getInstance(network); - return { - pools, - total: result.total, - page: result.page, - pageSize: result.pageSize, - }; - } catch (e) { - logger.error('Error in fetch-pools:', e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Error processing the request'); - } - }, + const result = await meteora.fetchPoolsFromApi({ + page, + limit, + query, + sortBy, + includeUnverified, }); -}; -export default fetchPoolsRoute; + // Map the API response to the shared format + const pools = result.pools.map((pool: MeteoraApiPool) => ({ + address: pool.address, + name: pool.name, + baseTokenAddress: pool.token_x.address, + baseTokenSymbol: pool.token_x.symbol, + quoteTokenAddress: pool.token_y.address, + quoteTokenSymbol: pool.token_y.symbol, + binStep: pool.pool_config.bin_step, + baseFee: pool.pool_config.base_fee_pct, + price: pool.current_price, + tvl: pool.tvl, + apr: pool.apr, + apy: pool.apy, + volume24h: pool.volume?.['24h'], + fees24h: pool.fees?.['24h'], + })); + + return { + pools, + total: result.total, + page: result.page, + pageSize: result.pageSize, + }; +} diff --git a/src/connectors/meteora/clmm-routes/index.ts b/src/connectors/meteora/clmm-routes/index.ts deleted file mode 100644 index c05793c64b..0000000000 --- a/src/connectors/meteora/clmm-routes/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { addLiquidityRoute } from './addLiquidity'; -import { closePositionRoute } from './closePosition'; -import { collectFeesRoute } from './collectFees'; -import { createPoolRoute } from './createPool'; -import { executeSwapRoute } from './executeSwap'; -import { fetchPoolsRoute } from './fetchPools'; -import { openPositionRoute } from './openPosition'; -import { poolInfoRoute } from './poolInfo'; -import { positionInfoRoute } from './positionInfo'; -import { positionsOwnedRoute } from './positionsOwned'; -import { quotePositionRoute } from './quotePosition'; -import { quoteSwapRoute } from './quoteSwap'; -import { removeLiquidityRoute } from './removeLiquidity'; - -export const meteoraClmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(fetchPoolsRoute); - await fastify.register(createPoolRoute); - await fastify.register(poolInfoRoute); - await fastify.register(positionsOwnedRoute); - await fastify.register(positionInfoRoute); - await fastify.register(quotePositionRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(executeSwapRoute); - await fastify.register(openPositionRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(collectFeesRoute); - await fastify.register(closePositionRoute); -}; - -export default meteoraClmmRoutes; diff --git a/src/connectors/meteora/clmm-routes/openPosition.ts b/src/connectors/meteora/clmm-routes/openPosition.ts index d60340c678..c5e18f5311 100644 --- a/src/connectors/meteora/clmm-routes/openPosition.ts +++ b/src/connectors/meteora/clmm-routes/openPosition.ts @@ -1,17 +1,14 @@ import { DecimalUtil } from '@orca-so/common-sdk'; -import { Static } from '@sinclair/typebox'; import { Keypair, PublicKey } from '@solana/web3.js'; import { BN } from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { OpenPositionResponse, OpenPositionResponseType } from '../../../schemas/clmm-schema'; +import { OpenPositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraClmmOpenPositionRequest } from '../schemas'; // Using Fastify's native error handling @@ -174,10 +171,9 @@ export async function openPosition( ]); // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -205,15 +201,15 @@ export async function openPosition( let baseAmountAdded = Math.abs(balanceChanges[0]); let quoteAmountAdded = Math.abs(balanceChanges[1]); - // When SOL is base/quote, wallet balance change includes: liquidity + rent + fee - // We need to subtract rent to get actual liquidity added + // When SOL is base/quote, the wallet paid liquidity + rent on that side, so back the + // rent out to leave the liquidity added. The transaction fee needs no correction: + // extractBalanceChangesAndFee nets it out for the fee payer and reports it separately, + // so subtracting it again would understate the amount by one fee. if (tokenXSymbol === 'SOL') { - // SOL is base token - subtract rent from balance change to get actual liquidity - baseAmountAdded = baseAmountAdded - positionRent - txFee; + baseAmountAdded = baseAmountAdded - positionRent; if (baseAmountAdded < 0) baseAmountAdded = 0; } else if (tokenYSymbol === 'SOL') { - // SOL is quote token - subtract rent from balance change to get actual liquidity - quoteAmountAdded = quoteAmountAdded - positionRent - txFee; + quoteAmountAdded = quoteAmountAdded - positionRent; if (quoteAmountAdded < 0) quoteAmountAdded = 0; } @@ -239,58 +235,3 @@ export async function openPosition( }; } } - -export const openPositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: OpenPositionResponseType; - }>( - '/open-position', - { - schema: { - description: 'Open a new Meteora position', - tags: ['/connector/meteora'], - body: MeteoraClmmOpenPositionRequest, - response: { - 200: OpenPositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - strategyType, - } = request.body; - const networkToUse = network; - - return await openPosition( - networkToUse, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - strategyType, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default openPositionRoute; diff --git a/src/connectors/meteora/clmm-routes/poolInfo.ts b/src/connectors/meteora/clmm-routes/poolInfo.ts index 2eb52ff1fb..b26675356b 100644 --- a/src/connectors/meteora/clmm-routes/poolInfo.ts +++ b/src/connectors/meteora/clmm-routes/poolInfo.ts @@ -1,9 +1,7 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { MeteoraPoolInfo, MeteoraPoolInfoSchema, GetPoolInfoRequestType, PoolInfo } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { MeteoraPoolInfo, PoolInfo } from '../../../schemas/clmm-schema'; import { Meteora } from '../meteora'; -import { MeteoraClmmGetPoolInfoRequest } from '../schemas'; export async function getPoolInfo( fastify: FastifyInstance, @@ -27,37 +25,3 @@ export async function getPoolInfo( return poolInfo; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPoolInfoRequestType; - Reply: MeteoraPoolInfo; - }>( - '/pool-info', - { - schema: { - description: 'Get pool information for a Meteora pool', - tags: ['/connector/meteora'], - querystring: MeteoraClmmGetPoolInfoRequest, - response: { - 200: MeteoraPoolInfoSchema, - }, - }, - }, - async (request) => { - try { - const { poolAddress } = request.query; - const network = request.query.network; - return (await getPoolInfo(fastify, network, poolAddress)) as MeteoraPoolInfo; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/meteora/clmm-routes/positionInfo.ts b/src/connectors/meteora/clmm-routes/positionInfo.ts index 558bc7cde9..09f21fbd30 100644 --- a/src/connectors/meteora/clmm-routes/positionInfo.ts +++ b/src/connectors/meteora/clmm-routes/positionInfo.ts @@ -1,9 +1,7 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { PositionInfo, PositionInfoSchema, GetPositionInfoRequestType } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { Meteora } from '../meteora'; -import { MeteoraClmmGetPositionInfoRequest } from '../schemas'; export async function getPositionInfo( fastify: FastifyInstance, @@ -22,37 +20,3 @@ export async function getPositionInfo( } return positionInfo; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get details for a specific Meteora position', - tags: ['/connector/meteora'], - querystring: MeteoraClmmGetPositionInfoRequest, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { positionAddress } = request.query; - const network = request.query.network; - return await getPositionInfo(fastify, network, positionAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/meteora/clmm-routes/positionsOwned.ts b/src/connectors/meteora/clmm-routes/positionsOwned.ts index 310b582686..5119bcc725 100644 --- a/src/connectors/meteora/clmm-routes/positionsOwned.ts +++ b/src/connectors/meteora/clmm-routes/positionsOwned.ts @@ -1,13 +1,11 @@ -import { Type } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { PositionInfo, PositionInfoSchema } from '../../../schemas/clmm-schema'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; -import { MeteoraClmmGetPositionsOwnedRequest, MeteoraClmmGetPositionsOwnedRequestType } from '../schemas'; + // Using Fastify's native error handling -const INVALID_SOLANA_ADDRESS_MESSAGE = (address: string) => `Invalid Solana address: ${address}`; export async function getPositionsOwned( fastify: FastifyInstance, @@ -40,40 +38,3 @@ async function fetchPositionsFromRPC(network: string, walletAddress: string): Pr logger.info(`Found ${positions.length} Meteora position(s) for wallet ${walletAddress.slice(0, 8)}...`); return positions; } - -export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: MeteoraClmmGetPositionsOwnedRequestType; - Reply: PositionInfo[]; - }>( - '/positions-owned', - { - schema: { - description: "Retrieve all positions owned by a user's wallet across all Meteora pools", - tags: ['/connector/meteora'], - querystring: MeteoraClmmGetPositionsOwnedRequest, - response: { - 200: Type.Array(PositionInfoSchema), - }, - }, - }, - async (request) => { - try { - const { network, walletAddress } = request.query; - return await getPositionsOwned(fastify, network, walletAddress); - } catch (e: any) { - logger.error(e); - if (e.statusCode) { - throw e; - } - // If it's an Error object with a message, use that message - if (e.message) { - throw fastify.httpErrors.serviceUnavailable(e.message); - } - throw fastify.httpErrors.internalServerError('Failed to fetch positions'); - } - }, - ); -}; - -export default positionsOwnedRoute; diff --git a/src/connectors/meteora/clmm-routes/quotePosition.ts b/src/connectors/meteora/clmm-routes/quotePosition.ts index 4174701309..c78634110a 100644 --- a/src/connectors/meteora/clmm-routes/quotePosition.ts +++ b/src/connectors/meteora/clmm-routes/quotePosition.ts @@ -1,15 +1,31 @@ -import { StrategyType, getPriceOfBinByBinId } from '@meteora-ag/dlmm'; -import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; +import { BN } from '@coral-xyz/anchor'; +import { StrategyType, autoFillXByStrategy, autoFillYByStrategy } from '@meteora-ag/dlmm'; +import { DecimalUtil } from '@orca-so/common-sdk'; +import { Decimal } from 'decimal.js'; -import { Solana } from '../../../chains/solana/solana'; -import { QuotePositionResponseType, QuotePositionResponse } from '../../../schemas/clmm-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { QuotePositionResponseType } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraClmmQuotePositionRequest } from '../schemas'; +const toLamports = (amount: number, decimals: number): BN => new BN(DecimalUtil.toBN(new Decimal(amount), decimals)); + +const fromLamports = (amount: BN, decimals: number): number => Number(amount.toString()) / Math.pow(10, decimals); + +/** + * Quote what a DLMM position of this shape would actually take. + * + * The paired amount comes from the SDK's autoFill helpers, which take the active bin, + * the bin step and the bin range and return what the strategy requires on the other + * side — the same math the open performs, so the quote describes the position the + * caller is about to create. + * + * It matters most for a range that does not straddle spot: such a position is entirely + * one-sided, and the unused side is 0. Pricing the paired amount off the range's + * midpoint instead — as this did — quoted a nonzero amount for a side the position + * cannot hold, and openPosition rejects exactly that, so following the quote produced + * a 400 from the open it fed. + */ export async function quotePosition( network: string, lowerPrice: number, @@ -21,93 +37,87 @@ export async function quotePosition( strategyType?: StrategyType, ): Promise { try { - const solana = await Solana.getInstance(network); const meteora = await Meteora.getInstance(network); - - // Get DLMM pool instance const dlmmPool = await meteora.getDlmmPool(poolAddress); - // Get current bin information - const activeBinId = dlmmPool.lbPair.activeId; - const binStep = dlmmPool.lbPair.binStep; + const baseDecimals = dlmmPool.tokenX.mint.decimals; + const quoteDecimals = dlmmPool.tokenY.mint.decimals; - // Calculate bin IDs from price range - const lowerBinId = dlmmPool.getBinIdFromPrice(lowerPrice, false); - const upperBinId = dlmmPool.getBinIdFromPrice(upperPrice, true); + // Derive the bin range exactly as openPosition does — per-lamport prices, and the + // rounding flags that way round. Passing raw prices with the flags swapped, as this + // did, described a different bin range than the position would occupy. + const lowerPricePerLamport = dlmmPool.toPricePerLamport(lowerPrice); + const upperPricePerLamport = dlmmPool.toPricePerLamport(upperPrice); + const minBinId = dlmmPool.getBinIdFromPrice(Number(lowerPricePerLamport), true); + const maxBinId = dlmmPool.getBinIdFromPrice(Number(upperPricePerLamport), false); - // Use provided strategy type or default to Spot - const strategy = { - minBinId: Math.min(lowerBinId, upperBinId), - maxBinId: Math.max(lowerBinId, upperBinId), - strategyType: strategyType ?? StrategyType.Spot, - }; + const activeBin = await dlmmPool.getActiveBin(); + const activeId = activeBin.binId; + const binStep = dlmmPool.lbPair.binStep; + const strategy = strategyType ?? MeteoraConfig.config.strategyType; + const amountXInActiveBin = new BN(activeBin.xAmount.toString()); + const amountYInActiveBin = new BN(activeBin.yAmount.toString()); - // Get token amounts needed for the position const slippage = slippagePct / 100; - // Calculate liquidity distribution if amounts are provided + /** What the strategy needs on the quote side for a given base amount. */ + const fillQuote = (base: number): number => + fromLamports( + autoFillYByStrategy( + activeId, + binStep, + toLamports(base, baseDecimals), + amountXInActiveBin, + amountYInActiveBin, + minBinId, + maxBinId, + strategy, + ), + quoteDecimals, + ); + + /** What the strategy needs on the base side for a given quote amount. */ + const fillBase = (quote: number): number => + fromLamports( + autoFillXByStrategy( + activeId, + binStep, + toLamports(quote, quoteDecimals), + amountXInActiveBin, + amountYInActiveBin, + minBinId, + maxBinId, + strategy, + ), + baseDecimals, + ); + let baseAmount = 0; let quoteAmount = 0; - let baseAmountMax = 0; - let quoteAmountMax = 0; let baseLimited = false; - let liquidityValue = '0'; - - if (baseTokenAmount || quoteTokenAmount) { - // Get current price adjusted for decimals - const rawPrice = getPriceOfBinByBinId(activeBinId, binStep).toNumber(); - const decimalDiff = dlmmPool.tokenX.mint.decimals - dlmmPool.tokenY.mint.decimals; - const adjustmentFactor = Math.pow(10, decimalDiff); - const currentPrice = rawPrice * adjustmentFactor; - // Calculate amounts based on strategy - if (baseTokenAmount && !quoteTokenAmount) { - baseLimited = true; + if (baseTokenAmount && !quoteTokenAmount) { + baseLimited = true; + baseAmount = baseTokenAmount; + quoteAmount = fillQuote(baseTokenAmount); + } else if (quoteTokenAmount && !baseTokenAmount) { + baseLimited = false; + quoteAmount = quoteTokenAmount; + baseAmount = fillBase(quoteTokenAmount); + } else if (baseTokenAmount && quoteTokenAmount) { + // Ask what the offered base would require on the quote side. If that needs more + // quote than the caller has, the quote side binds; otherwise the base does. This + // asks the strategy rather than comparing a ratio against spot, which was wrong + // for any range not centred on the current price. + const quoteNeeded = fillQuote(baseTokenAmount); + baseLimited = quoteNeeded <= quoteTokenAmount; + + if (baseLimited) { baseAmount = baseTokenAmount; - baseAmountMax = baseTokenAmount * (1 + slippage); - // Estimate quote amount based on price range and strategy - const avgPrice = (lowerPrice + upperPrice) / 2; - quoteAmount = baseAmount * avgPrice; - quoteAmountMax = quoteAmount * (1 + slippage); - } else if (quoteTokenAmount && !baseTokenAmount) { - baseLimited = false; + quoteAmount = quoteNeeded; + } else { quoteAmount = quoteTokenAmount; - quoteAmountMax = quoteTokenAmount * (1 + slippage); - // Estimate base amount based on price range and strategy - const avgPrice = (lowerPrice + upperPrice) / 2; - baseAmount = quoteAmount / avgPrice; - baseAmountMax = baseAmount * (1 + slippage); - } else if (baseTokenAmount && quoteTokenAmount) { - // Both amounts provided - use ratio to determine limiting token - const providedRatio = quoteTokenAmount / baseTokenAmount; - baseLimited = providedRatio > currentPrice; - - if (baseLimited) { - baseAmount = baseTokenAmount; - baseAmountMax = baseTokenAmount * (1 + slippage); - quoteAmount = baseTokenAmount * currentPrice; - quoteAmountMax = quoteAmount * (1 + slippage); - } else { - quoteAmount = quoteTokenAmount; - quoteAmountMax = quoteTokenAmount * (1 + slippage); - baseAmount = quoteTokenAmount / currentPrice; - baseAmountMax = baseAmount * (1 + slippage); - } - } - - // Calculate liquidity estimate - // For DLMM pools, liquidity is distributed across bins based on the strategy - // We'll estimate it based on the token amounts in lamports - try { - const tokenXAmountLamports = baseAmount * Math.pow(10, dlmmPool.tokenX.mint.decimals); - const tokenYAmountLamports = quoteAmount * Math.pow(10, dlmmPool.tokenY.mint.decimals); - - // For a balanced position, liquidity can be approximated as the geometric mean - // This is a simplified estimate; actual distribution depends on bin strategy - const estimatedLiquidity = Math.floor(Math.sqrt(tokenXAmountLamports * tokenYAmountLamports)); - liquidityValue = estimatedLiquidity.toString(); - } catch (error) { - logger.warn('Failed to calculate liquidity estimate:', error); + baseAmount = fillBase(quoteTokenAmount); } } @@ -115,64 +125,14 @@ export async function quotePosition( baseLimited, baseTokenAmount: baseAmount, quoteTokenAmount: quoteAmount, - baseTokenAmountMax: baseAmountMax, - quoteTokenAmountMax: quoteAmountMax, - liquidity: liquidityValue, + baseTokenAmountMax: baseAmount * (1 + slippage), + quoteTokenAmountMax: quoteAmount * (1 + slippage), + // No `liquidity`: the geometric mean this used to report collapses to 0 for any + // one-sided position, which is most of them. The field is optional in the schema + // and Orca already omits it. }; } catch (error) { logger.error(error); throw error; } } - -export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: Static; - Reply: QuotePositionResponseType; - }>( - '/quote-position', - { - schema: { - description: 'Quote amounts for a new Meteora CLMM position', - tags: ['/connector/meteora'], - querystring: MeteoraClmmQuotePositionRequest, - response: { - 200: QuotePositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - strategyType, - } = request.query; - - return await quotePosition( - network, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - strategyType, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw httpErrors.internalServerError('Failed to quote position'); - } - }, - ); -}; - -export default quotePositionRoute; diff --git a/src/connectors/meteora/clmm-routes/quoteSwap.ts b/src/connectors/meteora/clmm-routes/quoteSwap.ts index 1baabe6f3c..1fa85242fa 100644 --- a/src/connectors/meteora/clmm-routes/quoteSwap.ts +++ b/src/connectors/meteora/clmm-routes/quoteSwap.ts @@ -2,17 +2,12 @@ import { SwapQuoteExactOut, SwapQuote } from '@meteora-ag/dlmm'; import { DecimalUtil } from '@orca-so/common-sdk'; import { BN } from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; -import { estimateGasSolana } from '../../../chains/solana/routes/estimate-gas'; import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapResponseType, QuoteSwapResponse } from '../../../schemas/clmm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; -import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Meteora } from '../meteora'; import { MeteoraConfig } from '../meteora.config'; -import { MeteoraClmmQuoteSwapRequest, MeteoraClmmQuoteSwapRequestType } from '../schemas'; export async function getRawSwapQuote( network: string, @@ -92,6 +87,23 @@ async function formatSwapQuote( throw httpErrors.notFound('Failed to get pool tokens'); } + // The spot price to measure the quote against, in the same orientation the response + // reports its price: quote per base, for the pair as the caller named it. A DLMM's + // active bin is priced tokenY per tokenX, so it inverts when the caller's base is Y. + const activeBin = await dlmmPool.getActiveBin(); + const baseMint = side === 'SELL' ? inputToken.address : outputToken.address; + const pricePerToken = Number(activeBin?.pricePerToken ?? 0); + const spotPrice = + baseMint === dlmmPool.tokenX.publicKey.toBase58() ? pricePerToken : pricePerToken > 0 ? 1 / pricePerToken : 0; + + // What the quote costs against that spot, as a percentage — the same measure Orca + // reports, and it includes the pool fee, since it is taken from the executed price + // rather than from depth alone. This route used to return a hardcoded 0, so a swap of + // any size through a Meteora pool claimed zero impact and a caller could not tell that + // from a real measurement. + const priceImpactFrom = (executionPrice: number): number => + spotPrice > 0 ? Math.abs((executionPrice - spotPrice) / spotPrice) * 100 : 0; + if (side === 'BUY') { const exactOutQuote = quote as SwapQuoteExactOut; const estimatedAmountIn = DecimalUtil.fromBN(exactOutQuote.inAmount, inputToken.decimals).toNumber(); @@ -116,7 +128,7 @@ async function formatSwapQuote( minAmountOut: amountOut, maxAmountIn, // CLMM-specific fields - priceImpactPct: 0, // TODO: Calculate actual price impact + priceImpactPct: priceImpactFrom(price), }; } else { const exactInQuote = quote as SwapQuote; @@ -127,9 +139,6 @@ async function formatSwapQuote( // For sell orders: // - Base token (input) decreases (negative) // - Quote token (output) increases (positive) - const baseTokenChange = -estimatedAmountIn; - const quoteTokenChange = estimatedAmountOut; - const price = estimatedAmountOut / estimatedAmountIn; return { @@ -144,105 +153,11 @@ async function formatSwapQuote( minAmountOut, maxAmountIn: estimatedAmountIn, // CLMM-specific fields - priceImpactPct: 0, // TODO: Calculate actual price impact + priceImpactPct: priceImpactFrom(price), }; } } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: MeteoraClmmQuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Meteora CLMM', - tags: ['/connector/meteora'], - querystring: MeteoraClmmQuoteSwapRequest, - response: { - 200: QuoteSwapResponse, - }, - }, - }, - async (request) => { - try { - const { network, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = request.query; - const networkUsed = network; - - // Validate essential parameters - if (!baseToken || !quoteToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, quoteToken, amount, and side are required'); - } - - const solana = await Solana.getInstance(networkUsed); - - let poolAddressToUse = poolAddress; - - // If poolAddress is not provided, look it up by token pair - if (!poolAddressToUse) { - // Resolve token symbols to get proper symbols for pool lookup - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest( - sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken), - ); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'meteora', - networkUsed, - 'clmm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Meteora`, - ); - } - - poolAddressToUse = pool.address; - } - - const result = await formatSwapQuote( - networkUsed, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - poolAddressToUse, - slippagePct, - ); - - try { - // Note: estimateGasSolana returns feePerComputeUnit, not gasLimit - await estimateGasSolana(networkUsed); - } catch (error) { - logger.warn(`Failed to estimate gas for swap quote: ${error.message}`); - } - - return result; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Resolves the counter ("quote") token for a DLMM pool given the base token. The standardized swap * wrappers take poolAddress + baseToken and derive the other side from the pool (tokenX/tokenY), diff --git a/src/connectors/meteora/clmm-routes/removeLiquidity.ts b/src/connectors/meteora/clmm-routes/removeLiquidity.ts index 38bee36e56..23dc1db643 100644 --- a/src/connectors/meteora/clmm-routes/removeLiquidity.ts +++ b/src/connectors/meteora/clmm-routes/removeLiquidity.ts @@ -1,21 +1,13 @@ import { BN } from '@coral-xyz/anchor'; -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { - RemoveLiquidityResponse, - RemoveLiquidityRequestType, - RemoveLiquidityResponseType, -} from '../../../schemas/clmm-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Meteora } from '../meteora'; -import { MeteoraClmmRemoveLiquidityRequest } from '../schemas'; // Using centralized error handling -const INVALID_SOLANA_ADDRESS_MESSAGE = (address: string) => `Invalid Solana address: ${address}`; export async function removeLiquidity( network: string, @@ -97,10 +89,9 @@ export async function removeLiquidity( const fee = totalFee; // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -131,6 +122,10 @@ export async function removeLiquidity( signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: info.publicKey.toBase58(), fee, baseTokenAmountRemoved: tokenXRemovedAmount, quoteTokenAmountRemoved: tokenYRemovedAmount, @@ -143,41 +138,3 @@ export async function removeLiquidity( }; } } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a Meteora position', - tags: ['/connector/meteora'], - body: MeteoraClmmRemoveLiquidityRequest, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, positionAddress, liquidityPct } = request.body; - - const networkToUse = network; - - return await removeLiquidity(networkToUse, walletAddress, positionAddress, liquidityPct); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/meteora/meteora.routes.ts b/src/connectors/meteora/meteora.routes.ts deleted file mode 100644 index 5c7ca802e4..0000000000 --- a/src/connectors/meteora/meteora.routes.ts +++ /dev/null @@ -1,44 +0,0 @@ -import sensible from '@fastify/sensible'; -import type { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { meteoraAmmRoutes } from './amm-routes'; -import { meteoraClmmRoutes } from './clmm-routes'; - -// CLMM routes including swap endpoints -const meteoraClmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/meteora']; - } - }); - - await instance.register(meteoraClmmRoutes); - }); -}; - -// AMM routes (DAMM v2 / cp-amm), including swap endpoints -const meteoraAmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/meteora']; - } - }); - - await instance.register(meteoraAmmRoutes); - }); -}; - -// Export the CLMM and AMM routes -export const meteoraRoutes = { - clmm: meteoraClmmRoutesWrapper, - amm: meteoraAmmRoutesWrapper, -}; - -export default meteoraRoutes; diff --git a/src/connectors/meteora/meteora.ts b/src/connectors/meteora/meteora.ts index a346c12c4f..5a19cdb9e4 100644 --- a/src/connectors/meteora/meteora.ts +++ b/src/connectors/meteora/meteora.ts @@ -1,6 +1,5 @@ -import DLMM, { getPriceOfBinByBinId, LbPair, LBCLMM_PROGRAM_IDS } from '@meteora-ag/dlmm'; -import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; -import { PublicKey, MemcmpFilter } from '@solana/web3.js'; +import DLMM, { getPriceOfBinByBinId, LbPair } from '@meteora-ag/dlmm'; +import { PublicKey } from '@solana/web3.js'; import { Solana } from '../../chains/solana/solana'; import { MeteoraPoolInfo, PositionInfo, BinLiquidity } from '../../schemas/clmm-schema'; diff --git a/src/connectors/meteora/meteora.utils.ts b/src/connectors/meteora/meteora.utils.ts index 6b9c5694f7..795ffc4298 100644 --- a/src/connectors/meteora/meteora.utils.ts +++ b/src/connectors/meteora/meteora.utils.ts @@ -1,5 +1,3 @@ -import { MeteoraConfig } from './meteora.config'; - /** * Find a pool address for a token pair in the configured pools * diff --git a/src/connectors/meteora/schemas.ts b/src/connectors/meteora/schemas.ts deleted file mode 100644 index 14d0d2ebb0..0000000000 --- a/src/connectors/meteora/schemas.ts +++ /dev/null @@ -1,844 +0,0 @@ -import { StrategyType } from '@meteora-ag/dlmm'; -import { Type, Static } from '@sinclair/typebox'; - -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -import { MeteoraConfig } from './meteora.config'; - -// Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); - -// Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.01; -const BASE_TOKEN_AMOUNT = 0.01; -const QUOTE_TOKEN_AMOUNT = 2; -const LOWER_PRICE_BOUND = 150; -const UPPER_PRICE_BOUND = 250; -const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; - -// Meteora Router-specific extensions for quote-swap -export const MeteoraQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), - poolAddress: Type.Optional(Type.String()), -}); - -// Meteora Router-specific extensions for quote-swap response -export const MeteoraQuoteSwapResponse = Type.Object({ - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - exchangeRate: Type.Number(), - priceImpactPct: Type.Number(), - poolAddress: Type.String(), - fee: Type.Number(), - gasEstimate: Type.String(), - computeUnits: Type.Number(), -}); - -// Meteora CLMM-specific extensions -export const MeteoraClmmQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'The other token in the pair (optional - required if poolAddress not provided)', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), -}); - -// Export the type for QuoteSwapRequest -export type MeteoraClmmQuoteSwapRequestType = Static; - -export const MeteoraClmmExecuteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address (optional - required if poolAddress not provided)', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), -}); - -// Export the type for ExecuteSwapRequest -export type MeteoraClmmExecuteSwapRequestType = Static; - -// Meteora CLMM Open Position Request -export const MeteoraClmmOpenPositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will open the position', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Meteora DLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), - strategyType: Type.Optional( - Type.Number({ - description: 'Strategy type for the position', - examples: [StrategyType.Spot], - enum: Object.values(StrategyType).filter((x) => typeof x === 'number'), - }), - ), -}); - -// Meteora CLMM Add Liquidity Request -export const MeteoraClmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will add liquidity', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), - strategyType: Type.Optional( - Type.Number({ - description: 'Strategy type for the position', - examples: [StrategyType.Spot], - enum: Object.values(StrategyType).filter((x) => typeof x === 'number'), - }), - ), -}); - -// Meteora CLMM Remove Liquidity Request -export const MeteoraClmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will remove liquidity', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), - liquidityPct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - default: 100, - examples: [100], - }), - ), -}); - -// Meteora CLMM Close Position Request -export const MeteoraClmmClosePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will close the position', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), -}); - -// Meteora CLMM Collect Fees Request -export const MeteoraClmmCollectFeesRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will collect fees', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), -}); - -// Meteora CLMM Fetch Pools Request -export const MeteoraClmmFetchPoolsRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - page: Type.Optional( - Type.Number({ - minimum: 0, - default: 0, - description: 'Page number (0-based)', - examples: [0], - }), - ), - limit: Type.Optional( - Type.Number({ - minimum: 1, - maximum: 1000, - default: 50, - description: 'Maximum number of pools to return (max 1000)', - examples: [50], - }), - ), - query: Type.Optional( - Type.String({ - description: 'Search query to match pools by name, tokens, or address', - examples: ['SOL', 'USDC', 'SOL-USDC'], - }), - ), - sortBy: Type.Optional( - Type.String({ - description: 'Sort by field (volume, fees, tvl, apr) with optional time window', - default: 'volume_24h:desc', - examples: ['volume_24h:desc', 'tvl:desc', 'apr:desc'], - }), - ), - includeUnverified: Type.Optional( - Type.Boolean({ - description: 'Include pools with unverified tokens', - default: true, - }), - ), -}); - -// Meteora CLMM Get Pool Info Request -export const MeteoraClmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Meteora DLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), -}); - -// Meteora CLMM Get Position Info Request -export const MeteoraClmmGetPositionInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), -}); - -// Meteora CLMM Get Positions Owned Request -export const MeteoraClmmGetPositionsOwnedRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.String({ - description: 'Solana wallet address to check for positions', - examples: [solanaChainConfig.defaultWallet], - }), -}); - -export type MeteoraClmmGetPositionsOwnedRequestType = Static; - -// Meteora CLMM Quote Position Request -export const MeteoraClmmQuotePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Meteora DLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), - strategyType: Type.Optional( - Type.Number({ - description: 'Strategy type for the position', - examples: [StrategyType.Spot], - enum: Object.values(StrategyType).filter((x) => typeof x === 'number'), - }), - ), -}); - -// Meteora CLMM Create Pool Request -export const MeteoraClmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will create the pool', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - initialPrice: Type.Number({ - description: 'Initial price as quote per base (e.g. USDC per SOL). Encodes the pool active bin.', - examples: [UPPER_PRICE_BOUND], - }), - binStep: Type.Number({ - description: - 'Bin step in basis points (e.g. 1, 2, 4, 5, 10, 20, 25, 50, 100). Sets pool granularity; ' + - 'cannot be changed after creation.', - examples: [20], - }), - feeBps: Type.Number({ - description: 'Base swap fee in basis points (e.g. 20 = 0.20%). Must be compatible with binStep.', - examples: [20], - }), -}); - -// ======================================== -// DAMM v2 (AMM) Request Schemas -// ======================================== - -const DAMM_V2_POOL_ADDRESS_EXAMPLE = 'FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv'; - -export const MeteoraAmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Meteora DAMM v2 pool address', - examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], - }), -}); - -export const MeteoraAmmGetPositionInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Meteora DAMM v2 pool address', - examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], - }), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), -}); - -export const MeteoraAmmQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Meteora DAMM v2 pool address', - examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], - }), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'The other token in the pair (optional - resolved from the pool if omitted)', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap (denominated in the base token)', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), -}); - -export const MeteoraAmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Meteora DAMM v2 pool address', - examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], - }), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'The other token in the pair (optional - resolved from the pool if omitted)', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap (denominated in the base token)', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), -}); - -export const MeteoraAmmQuoteLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Meteora DAMM v2 pool address', - examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - examples: [QUOTE_TOKEN_AMOUNT], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), -}); - -export const MeteoraAmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Meteora DAMM v2 pool address', - examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - examples: [QUOTE_TOKEN_AMOUNT], - }), - positionAddress: Type.Optional( - Type.String({ - description: - 'DAMM v2 positions are NFTs; a wallet may hold several per pool. Provide a position address ' + - '(from position-info) to add to that specific position; omit to open a NEW position NFT.', - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: MeteoraConfig.config.slippagePct, - examples: [MeteoraConfig.config.slippagePct], - }), - ), -}); - -export const MeteoraAmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Meteora DAMM v2 pool address', - examples: [DAMM_V2_POOL_ADDRESS_EXAMPLE], - }), - positionAddress: Type.String({ - description: - 'Address of the specific DAMM v2 position (NFT) to remove from. Required — a wallet may hold ' + - 'several positions per pool; list them with position-info. This avoids silently draining only ' + - 'the largest position when several exist.', - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of this position’s liquidity to remove', - examples: [100], - }), -}); - -export const MeteoraAmmGetPositionsOwnedRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.String({ - description: 'Solana wallet address to list DAMM v2 positions for', - examples: [solanaChainConfig.defaultWallet], - }), -}); -export type MeteoraAmmGetPositionsOwnedRequestType = Static; - -export const MeteoraAmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...MeteoraConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will create and seed the pool', - default: solanaChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes pool token A)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes pool token B)', - examples: [QUOTE_TOKEN], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to seed the pool with', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: - 'Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. ' + - 'If omitted (and no initialPrice), the current market price is fetched from the swap router.', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base (e.g. SOL per UMBRA). Overrides quoteTokenAmount. ' + - 'If both are omitted, the pool is seeded at the current market price so it is not immediately arbitraged.', - }), - ), - configAddress: Type.Optional( - Type.String({ - description: - 'DAMM v2 config account that defines the fee tier and pool parameters. Required — many permissionless ' + - 'configs are launch configs with very high starting fees, so Gateway does not auto-select one.', - }), - ), -}); diff --git a/src/connectors/okx/README.md b/src/connectors/okx/README.md index 2f8f674170..bb620a2da4 100644 --- a/src/connectors/okx/README.md +++ b/src/connectors/okx/README.md @@ -1,28 +1,31 @@ # OKX DEX Aggregator Router Connector The [OKX DEX aggregator](https://web3.okx.com/dex-swap) (part of OKX OnchainOS) routes swaps -across Solana DEX liquidity. This connector exposes it through Gateway's standard router -endpoints: +across Solana DEX liquidity. Like every router connector it is reached through the unified +trading routes, naming `okx` as the `connector`: -- `GET /connectors/okx/router/quote-swap` -- `POST /connectors/okx/router/execute-quote` -- `POST /connectors/okx/router/execute-swap` +- `GET /trading/router/quote-swap` +- `POST /trading/router/execute-quote` +- `POST /trading/router/execute-swap` Network support: `mainnet-beta` only (OKX `chainIndex` 501). ## Getting API credentials OKX requires **three credentials** — an API key, a secret key, and a passphrase — used to -HMAC-sign every request. All three are created in the OKX developer portal: +HMAC-sign every request. Creating them is self-serve and free; only email and phone +verification are needed to start. 1. Create an OKX account and sign in to the developer portal: https://web3.okx.com/build/dev-portal -2. Create a **project** (up to three projects per account). -3. In the project, open the **API keys** page and click **Create API key** (up to three keys +2. Verify email and phone. That alone puts the account on the **Trial** tier. +3. Create a **project** (up to three projects per account). +4. In the project, open the **API keys** page and click **Create API key** (up to three keys per project). Enter a name and choose a **passphrase**. -4. Save all three values — the **API key**, the system-generated **secret key**, and your - **passphrase**. OKX cannot recover the passphrase; without it the key is unusable. -5. Add them to `conf/connectors/okx.yml`: +5. Save all three values — the **API key**, the system-generated **secret key** (shown only + at creation), and your **passphrase**. OKX cannot recover the passphrase; without it the + key is unusable. +6. Add them to `conf/connectors/okx.yml`: ```yaml apiKey: 'your-okx-api-key' @@ -34,6 +37,21 @@ The connector fails fast with a clear error if any of the three is missing. Trea credentials: keep them out of version control (`conf/` is gitignored) and rotate them if exposed. +### What a Trial key can actually do + +The trial is **60 days at 1 request/second**, raisable to 5 RPS on review. That ceiling, +not the expiry, is what usually bites: a bot sweeping quotes across several connectors +exceeds 1 RPS on its own. Continuing past 60 days means completing KYC in the developer +portal to reach the **Start-up** tier, which is where usable rate limits are. Start-up has +no per-call charge, but partners who take a fee on swaps enter a revenue-share where OKX +retains 20% of it. + +Worth knowing before spending time on a 401: OKX's own client library sends a fifth header, +`OK-ACCESS-PROJECT`, carrying the project ID. This connector does not, and has no config +field for one — that library targets the v5 API, while this connector calls v6, whose quote +reference lists only the four headers below. If signed requests are rejected once +credentials are populated, add the project ID before investigating anything else. + ## How requests are signed Every request sends the headers `OK-ACCESS-KEY`, `OK-ACCESS-PASSPHRASE`, @@ -69,5 +87,7 @@ the request path (see `okx.ts:signedHeaders`). - DEX API reference: https://web3.okx.com/onchainos/dev-docs/trade/dex-api-reference - Developer portal guide: https://web3.okx.com/onchainos/dev-docs/home/developer-portal +- Authentication: https://web3.okx.com/onchainos/dev-docs/home/api-access-and-usage +- Tiers and fees: https://web3.okx.com/build/dev-docs/dex-api/dex-api-fee - Quote endpoint: https://web3.okx.com/build/dev-docs/wallet-api/dex-get-quote - Swap endpoint: https://web3.okx.com/build/dev-docs/wallet-api/dex-swap diff --git a/src/connectors/okx/okx.routes.ts b/src/connectors/okx/okx.routes.ts deleted file mode 100644 index d0a2a8bbe4..0000000000 --- a/src/connectors/okx/okx.routes.ts +++ /dev/null @@ -1,26 +0,0 @@ -import sensible from '@fastify/sensible'; -import type { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { okxRouterRoutes } from './router-routes'; - -// OKX routes with 3 endpoints -const okxRouterRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - // Decorate the instance with a hook to modify route options - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/okx']; - } - }); - - await instance.register(okxRouterRoutes); - }); -}; - -// Export routes in the same pattern as Jupiter -export const okxRoutes = { - router: okxRouterRoutesWrapper, -}; diff --git a/src/connectors/okx/okx.ts b/src/connectors/okx/okx.ts index b29983c0df..ce044ff35c 100644 --- a/src/connectors/okx/okx.ts +++ b/src/connectors/okx/okx.ts @@ -4,6 +4,7 @@ import { VersionedTransaction } from '@solana/web3.js'; import bs58 from 'bs58'; import { Solana } from '../../chains/solana/solana'; +import { httpErrors } from '../../services/error-handler'; import { createHttpClient, HttpClient, HttpClientError } from '../../services/http-client'; import { logger } from '../../services/logger'; @@ -54,7 +55,11 @@ export class Okx { this.solana = null; if (!this.config.apiKey || !this.config.secretKey || !this.config.passphrase) { - throw new Error( + // A missing credential is a configuration gap, not a Gateway fault. Thrown as a + // plain Error it reached callers as a 500, which reads as "retry later" for a + // condition no retry can fix — and OKX is advertised in /config/connectors, so + // anything enumerating providers hits it. + throw httpErrors.badRequest( 'OKX DEX API credentials are not configured. Set okx.apiKey, okx.secretKey and okx.passphrase ' + 'in conf/connectors/okx.yml (create them at https://web3.okx.com/build/dev-portal).', ); diff --git a/src/connectors/okx/router-routes/executeQuote.ts b/src/connectors/okx/router-routes/executeQuote.ts index a99b018951..603a9ad01e 100644 --- a/src/connectors/okx/router-routes/executeQuote.ts +++ b/src/connectors/okx/router-routes/executeQuote.ts @@ -1,12 +1,9 @@ -import { FastifyPluginAsync } from 'fastify'; - import { Solana } from '../../../chains/solana/solana'; -import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { Okx } from '../okx'; -import { OkxExecuteQuoteRequest } from '../schemas'; export async function executeQuote( walletAddress: string, @@ -41,18 +38,18 @@ export async function executeQuote( ); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + slippagePct, ); // Remove quote from cache only after successful execution (confirmed) @@ -65,33 +62,3 @@ export async function executeQuote( return result as SwapExecuteResponseType; } - -export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteQuoteRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-quote', - { - schema: { - description: 'Execute a previously fetched quote from the OKX DEX aggregator', - tags: ['/connector/okx'], - body: OkxExecuteQuoteRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, quoteId } = request.body as typeof OkxExecuteQuoteRequest._type; - - return await executeQuote(walletAddress, network, quoteId); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing OKX quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeQuoteRoute; diff --git a/src/connectors/okx/router-routes/executeSwap.ts b/src/connectors/okx/router-routes/executeSwap.ts index 672a8c2183..b7632b9f0c 100644 --- a/src/connectors/okx/router-routes/executeSwap.ts +++ b/src/connectors/okx/router-routes/executeSwap.ts @@ -1,10 +1,5 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; -import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { OkxConfig } from '../okx.config'; -import { OkxExecuteSwapRequest } from '../schemas'; import { executeQuote } from './executeQuote'; import { quoteSwap } from './quoteSwap'; @@ -35,43 +30,3 @@ async function executeSwap( } export { executeSwap }; - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Quote and execute a token swap on the OKX DEX aggregator in one step', - tags: ['/connector/okx'], - body: OkxExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut } = - request.body as typeof OkxExecuteSwapRequest._type; - - return await executeSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - approximateIfNoExactOut, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing OKX swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/okx/router-routes/index.ts b/src/connectors/okx/router-routes/index.ts deleted file mode 100644 index b3acb8e18c..0000000000 --- a/src/connectors/okx/router-routes/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import executeQuoteRoute from './executeQuote'; -import executeSwapRoute from './executeSwap'; -import quoteSwapRoute from './quoteSwap'; - -export const okxRouterRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(quoteSwapRoute); - await fastify.register(executeQuoteRoute); - await fastify.register(executeSwapRoute); -}; - -export default okxRouterRoutes; diff --git a/src/connectors/okx/router-routes/quoteSwap.ts b/src/connectors/okx/router-routes/quoteSwap.ts index e68860be1d..1d896d8470 100644 --- a/src/connectors/okx/router-routes/quoteSwap.ts +++ b/src/connectors/okx/router-routes/quoteSwap.ts @@ -1,18 +1,15 @@ import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapRequestType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { sanitizeErrorMessage, sanitizeString } from '../../../services/sanitize'; -import { approximateBuyViaSellLeg } from '../../router-utils'; +import { approximateBuyViaSellLeg, attemptedRoute } from '../../router-utils'; import { Okx, OkxRouterResult } from '../okx'; import { OkxConfig } from '../okx.config'; -import { OkxQuoteSwapRequest, OkxQuoteSwapResponse } from '../schemas'; - +import { OkxQuoteSwapResponse } from '../schemas'; function priceImpactPct(routerResult: OkxRouterResult): number { return parseFloat(routerResult.priceImpactPercent ?? routerResult.priceImpactPercentage ?? '0'); } @@ -74,8 +71,8 @@ export async function quoteSwap( executableSwapMode = 'exactIn'; isApproximation = true; } else { - const tokenPair = `${sanitizeString(baseToken)} -> ${sanitizeString(quoteToken)}`; - throw httpErrors.noRouteFound(`No route found for ${tokenPair} (${executableSwapMode}). ${errorMessage}`); + const route = attemptedRoute(side, sanitizeString(baseToken), sanitizeString(quoteToken), executableSwapMode); + throw httpErrors.noRouteFound(`No route found for ${route}. ${errorMessage}`); } } @@ -128,42 +125,3 @@ export async function quoteSwap( routerResult, }; } - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: Static; - }>( - '/quote-swap', - { - schema: { - description: 'Get an executable swap quote from the OKX DEX aggregator', - tags: ['/connector/okx'], - querystring: OkxQuoteSwapRequest, - response: { 200: OkxQuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut } = - request.query as typeof OkxQuoteSwapRequest._type; - - return await quoteSwap( - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - approximateIfNoExactOut, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error getting OKX quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/connectors/okx/schemas.ts b/src/connectors/okx/schemas.ts index 7347033f39..2cb82f64a2 100644 --- a/src/connectors/okx/schemas.ts +++ b/src/connectors/okx/schemas.ts @@ -1,60 +1,8 @@ import { Type } from '@sinclair/typebox'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -import { OkxConfig } from './okx.config'; - // Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); // Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.1; - -// OKX-specific quote-swap request (superset of base QuoteSwapRequest) -export const OkxQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OkxConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OkxConfig.config.slippagePct, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders when OKX cannot serve an exactOut quote: approximate via a sell-leg exactIn quote instead of failing', - default: true, - }), - ), -}); // OKX-specific quote-swap response (superset of base QuoteSwapResponse) export const OkxQuoteSwapResponse = Type.Object({ @@ -95,74 +43,3 @@ export const OkxQuoteSwapResponse = Type.Object({ description: "OKX's native quote result (amounts, price impact, routing breakdown)", }), }); - -// OKX-specific execute-quote request (superset of base ExecuteQuoteRequest) -export const OkxExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OkxConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the OKX quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), -}); - -// OKX-specific execute-swap request (superset of base ExecuteSwapRequest) -export const OkxExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OkxConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OkxConfig.config.slippagePct, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders when OKX cannot serve an exactOut quote: approximate via a sell-leg exactIn quote instead of failing', - default: true, - }), - ), -}); diff --git a/src/connectors/orca/clmm-routes/addLiquidity.ts b/src/connectors/orca/clmm-routes/addLiquidity.ts index 24b388b11e..3050757ad1 100644 --- a/src/connectors/orca/clmm-routes/addLiquidity.ts +++ b/src/connectors/orca/clmm-routes/addLiquidity.ts @@ -5,20 +5,18 @@ import { increaseLiquidityQuoteB, type IncreaseLiquidityQuote, } from '@orca-so/whirlpools-core'; -import { Static } from '@sinclair/typebox'; import { address } from '@solana/kit'; import { PublicKey } from '@solana/web3.js'; import { fetchAllMint } from '@solana-program/token-2022'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas/clmm-schema'; +import { AddLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { getCurrentTransferFee } from '../orca.position'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; -import { OrcaClmmAddLiquidityRequest } from '../schemas'; export async function addLiquidity( network: string, @@ -26,7 +24,7 @@ export async function addLiquidity( positionAddress: string, baseTokenAmount: number, quoteTokenAmount: number, - slippagePct: number, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { if ((!baseTokenAmount || baseTokenAmount <= 0) && (!quoteTokenAmount || quoteTokenAmount <= 0)) { throw httpErrors.badRequest('At least one token amount must be provided and greater than 0'); @@ -100,7 +98,12 @@ export async function addLiquidity( const result = await increaseLiquidityInstructions( orca.solanaKitRpc, position.data.positionMint, - { tokenMaxA: quote.tokenMaxA, tokenMaxB: quote.tokenMaxB }, + // The estimates, not the quote's ceilings — the same correction as openPosition. + // increaseLiquidityQuote* already applied slippageBps to produce tokenMax*, and this + // builder applies slippageToleranceBps again to derive the on-chain maximums, so + // passing tokenMax* deposits slippagePct more than was asked for. The log line above + // has always reported tokenEst*, which is what the transaction should be spending. + { tokenMaxA: quote.tokenEstA, tokenMaxB: quote.tokenEstB }, { authority: createOrcaAuthority(walletAddress), slippageToleranceBps: slippageBps, @@ -128,52 +131,13 @@ export async function addLiquidity( signature, status: 1, data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: position.data.whirlpool.toString(), fee, baseTokenAmountAdded: Math.abs(balanceChanges[0]), quoteTokenAmountAdded: Math.abs(balanceChanges[1]), }, }; } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to an Orca position', - tags: ['/connector/orca'], - body: OrcaClmmAddLiquidityRequest, - response: { 200: AddLiquidityResponse }, - }, - }, - async (request) => { - try { - const { - walletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct = 1, - network, - } = request.body; - return await addLiquidity( - network, - walletAddress, - positionAddress, - baseTokenAmount || 0, - quoteTokenAmount || 0, - slippagePct, - ); - } catch (error) { - logger.error(error); - if (error.statusCode) throw error; - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/orca/clmm-routes/closePosition.ts b/src/connectors/orca/clmm-routes/closePosition.ts index 17cb745e6e..2e84266c53 100644 --- a/src/connectors/orca/clmm-routes/closePosition.ts +++ b/src/connectors/orca/clmm-routes/closePosition.ts @@ -1,25 +1,23 @@ import { closePositionInstructions } from '@orca-so/whirlpools'; import { fetchMaybePosition, fetchWhirlpool } from '@orca-so/whirlpools-client'; -import { Static } from '@sinclair/typebox'; import { address } from '@solana/kit'; import { getAssociatedTokenAddressSync } from '@solana/spl-token'; import { PublicKey } from '@solana/web3.js'; import { fetchMint } from '@solana-program/token-2022'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ClosePositionResponse, ClosePositionResponseType } from '../../../schemas/clmm-schema'; +import { ClosePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; import { extractInnerTransferAmounts } from '../orca.utils'; -import { OrcaClmmClosePositionRequest } from '../schemas'; export async function closePosition( network: string, walletAddress: string, positionAddress: string, + slippagePct?: number, ): Promise { const solana = await Solana.getInstance(network); const orca = await Orca.getInstance(network); @@ -45,7 +43,10 @@ export async function closePosition( // and destination account setup/cleanup. Gateway remains the only signer. const closeResult = await closePositionInstructions(orca.solanaKitRpc, position.data.positionMint, { authority: createOrcaAuthority(walletAddress), - slippageToleranceBps: Math.round(orca.config.slippagePct * 100), + // The caller's tolerance when they set one — an executor widening across retries is + // the case this exists for; a narrow in-range close can fail on slippage at the + // connector's configured value with no way to say "accept more to get out". + slippageToleranceBps: Math.round((slippagePct ?? orca.config.slippagePct) * 100), whirlpoolDeployment: orca.deployment, }); const rewardCount = closeResult.rewardsQuote.rewards.filter((reward) => reward.rewardsOwed > 0n).length; @@ -54,10 +55,9 @@ export async function closePosition( const transaction = buildOrcaTransaction(closeResult.instructions, walletAddress); const { signature, fee } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); let positionRentRefunded = 0; if (txData) { @@ -113,6 +113,10 @@ export async function closePosition( signature, status: 1, data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: position.data.whirlpool.toString(), fee, positionRentRefunded, baseTokenAmountRemoved, @@ -122,34 +126,3 @@ export async function closePosition( }, }; } - -export const closePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: ClosePositionResponseType; - }>( - '/close-position', - { - schema: { - description: 'Close an Orca position', - tags: ['/connector/orca'], - body: OrcaClmmClosePositionRequest, - response: { - 200: ClosePositionResponse, - }, - }, - }, - async (request) => { - try { - const { walletAddress, positionAddress, network } = request.body; - return await closePosition(network, walletAddress, positionAddress); - } catch (error) { - logger.error(error); - if (error.statusCode) throw error; - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default closePositionRoute; diff --git a/src/connectors/orca/clmm-routes/collectFees.ts b/src/connectors/orca/clmm-routes/collectFees.ts index 68d7809cea..02f9ce01d6 100644 --- a/src/connectors/orca/clmm-routes/collectFees.ts +++ b/src/connectors/orca/clmm-routes/collectFees.ts @@ -1,17 +1,13 @@ import { harvestPositionInstructions } from '@orca-so/whirlpools'; import { fetchPosition, fetchWhirlpool } from '@orca-so/whirlpools-client'; -import { Static } from '@sinclair/typebox'; import { address } from '@solana/kit'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CollectFeesResponse, CollectFeesResponseType } from '../../../schemas/clmm-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { CollectFeesResponseType } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; -import { OrcaClmmCollectFeesRequest } from '../schemas'; export async function collectFees( network: string, @@ -53,38 +49,13 @@ export async function collectFees( signature, status: 1, data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: position.data.whirlpool.toString(), fee, baseFeeAmountCollected: Math.abs(balanceChanges[0]), quoteFeeAmountCollected: Math.abs(balanceChanges[1]), }, }; } - -export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: CollectFeesResponseType; - }>( - '/collect-fees', - { - schema: { - description: 'Collect fees and rewards from an Orca position', - tags: ['/connector/orca'], - body: OrcaClmmCollectFeesRequest, - response: { 200: CollectFeesResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, positionAddress, network } = request.body; - return await collectFees(network, walletAddress, positionAddress); - } catch (error) { - logger.error(error); - if (error.statusCode) throw error; - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default collectFeesRoute; diff --git a/src/connectors/orca/clmm-routes/createPool.ts b/src/connectors/orca/clmm-routes/createPool.ts index e2a06b0338..3d33e345c1 100644 --- a/src/connectors/orca/clmm-routes/createPool.ts +++ b/src/connectors/orca/clmm-routes/createPool.ts @@ -1,17 +1,14 @@ import { createConcentratedLiquidityPoolInstructions, orderMints } from '@orca-so/whirlpools'; -import { Static } from '@sinclair/typebox'; import { address, type Instruction } from '@solana/kit'; import { Keypair, PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Orca } from '../orca'; import { buildOrcaTransaction, createOrcaAuthority, replaceOrcaInstructionAccounts } from '../orca.sdk'; -import { OrcaClmmCreatePoolRequest } from '../schemas'; /** Resolves a token symbol or mint address to a PublicKey. */ async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { @@ -31,11 +28,11 @@ async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { // Probe with 1 base token — we only need the price ratio, not a real trade size. - quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + quote = await getSwapQuote(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + @@ -144,10 +141,9 @@ export async function createPool( const transaction = buildOrcaTransaction(instructions, walletAddress); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, extraSigners); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { return { @@ -157,43 +153,8 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - // Pool created + initialized only — no liquidity/position seeded. - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: - 'Create and initialize a new Orca (Whirlpools) CLMM pool at an initial price. Does not open or seed a position.', - tags: ['/connector/orca'], - body: OrcaClmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, baseToken, quoteToken, initialPrice, tickSpacing } = request.body; - return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, tickSpacing); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/orca/clmm-routes/executeSwap.ts b/src/connectors/orca/clmm-routes/executeSwap.ts index 970f2c64e1..326112c7be 100644 --- a/src/connectors/orca/clmm-routes/executeSwap.ts +++ b/src/connectors/orca/clmm-routes/executeSwap.ts @@ -2,21 +2,17 @@ import { swapInstructions } from '@orca-so/whirlpools'; import { fetchWhirlpool } from '@orca-so/whirlpools-client'; import { address } from '@solana/kit'; import { fetchAllMint } from '@solana-program/token-2022'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; -import { ExecuteSwapResponseType, ExecuteSwapResponse } from '../../../schemas/clmm-schema'; +import { ExecuteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; -import { OrcaClmmExecuteSwapRequest, OrcaClmmExecuteSwapRequestType } from '../schemas'; import { resolveCounterToken } from './quoteSwap'; -const COMPUTE_BUDGET_PROGRAM_ID = address('ComputeBudget111111111111111111111111111111'); - export async function executeSwap( network: string, walletAddress: string, @@ -24,7 +20,7 @@ export async function executeSwap( baseTokenIdentifier: string, side: 'BUY' | 'SELL', amount: number, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { const solana = await Solana.getInstance(network); const orca = await Orca.getInstance(network); @@ -117,95 +113,7 @@ export async function executeSwap( fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: OrcaClmmExecuteSwapRequestType; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a token swap on Orca CLMM', - tags: ['/connector/orca'], - body: OrcaClmmExecuteSwapRequest, - response: { 200: ExecuteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, walletAddress, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = request.body; - - // Use defaults if not provided - const networkUsed = network || getSolanaChainConfig().defaultNetwork; - const walletAddressUsed = walletAddress || getSolanaChainConfig().defaultWallet; - - let poolAddressUsed = poolAddress; - - // If poolAddress is not provided, look it up by token pair - if (!poolAddressUsed) { - const solana = await Solana.getInstance(networkUsed); - - // Resolve token symbols to get proper symbols for pool lookup - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest(`Token not found: ${!baseTokenInfo ? baseToken : quoteToken}`); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'orca', - networkUsed, - 'clmm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Orca`, - ); - } - - poolAddressUsed = pool.address; - } - logger.info(`Received swap request: ${amount} ${baseToken} -> ${quoteToken} in pool ${poolAddressUsed}`); - - return await executeSwap( - networkUsed, - walletAddressUsed, - poolAddressUsed, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - } catch (e: any) { - logger.error('Error executing swap:', e.message || e); - - if (e.statusCode) { - // If it's already an HTTP error, throw it properly - throw e; - } - - // Check for specific error messages - const errorMessage = e.message || e.toString(); - if (errorMessage.includes('503') || errorMessage.includes('Service Unavailable')) { - throw httpErrors.serviceUnavailable('RPC service temporarily unavailable. Please try again.'); - } - - throw httpErrors.internalServerError(`Swap execution failed: ${errorMessage}`); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/orca/clmm-routes/fetchPools.ts b/src/connectors/orca/clmm-routes/fetchPools.ts index ba56259bd9..92d186b653 100644 --- a/src/connectors/orca/clmm-routes/fetchPools.ts +++ b/src/connectors/orca/clmm-routes/fetchPools.ts @@ -1,74 +1,57 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { FetchPoolsResponse } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { FetchPoolsResponseType } from '../../../schemas/clmm-schema'; import { Orca } from '../orca'; -import { OrcaClmmFetchPoolsRequest } from '../schemas'; - -export const fetchPoolsRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: { - network?: string; - limit?: number; - query?: string; - sortBy?: string; - sortDirection?: string; - verifiedOnly?: boolean; - }; - }>('/fetch-pools', { - schema: { - description: 'Fetch Orca pools from API with search and sorting', - tags: ['/connector/orca'], - querystring: OrcaClmmFetchPoolsRequest, - response: { - 200: FetchPoolsResponse, - }, - }, - handler: async (request, _reply) => { - try { - const { network, limit = 50, query, sortBy, sortDirection, verifiedOnly } = request.query; - const orca = await Orca.getInstance(network); +export interface OrcaFetchPoolsArgs { + network: string; + limit?: number; + query?: string; + sortBy?: string; + sortDirection?: string; + verifiedOnly?: boolean; +} - const rawPools = await orca.fetchPoolsFromApi({ - limit, - query, - sortBy, - sortDirection, - verifiedOnly, - }); +/** + * Fetch pools from Orca's own pool-discovery API and normalize them into the + * shared FetchPoolsResponse shape. Reached through GET /trading/clmm/fetch-pools. + * + * Orca's API returns a flat list with no total count and no pagination, so the + * response reports page 1 and a total equal to what came back. + */ +export async function fetchPools(args: OrcaFetchPoolsArgs): Promise { + const { network, limit = 50, query, sortBy, sortDirection, verifiedOnly } = args; - // Map to standardized format (same as Meteora) - const pools = rawPools.map((pool: any) => ({ - address: pool.address, - name: `${pool.tokenA?.symbol || '?'}-${pool.tokenB?.symbol || '?'}`, - baseTokenAddress: pool.tokenMintA, - baseTokenSymbol: pool.tokenA?.symbol || '', - quoteTokenAddress: pool.tokenMintB, - quoteTokenSymbol: pool.tokenB?.symbol || '', - binStep: pool.tickSpacing, - baseFee: Number(pool.feeRate) / 10000, // Convert to percentage - price: Number(pool.price), - tvl: Number(pool.tvlUsdc) || 0, - apr: pool.feeApr?.day ? Number(pool.feeApr.day) * 100 : undefined, // Convert to percentage - apy: pool.totalApr?.day ? Number(pool.totalApr.day) * 100 : undefined, - volume24h: pool.volume?.day ? Number(pool.volume.day) : undefined, - fees24h: pool.fees?.day ? Number(pool.fees.day) : undefined, - })); + const orca = await Orca.getInstance(network); - return { - pools, - total: pools.length, // Orca API doesn't return total count - page: 1, - pageSize: limit, - }; - } catch (e) { - logger.error('Error in fetch-pools:', e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Error processing the request'); - } - }, + const rawPools = await orca.fetchPoolsFromApi({ + limit, + query, + sortBy, + sortDirection, + verifiedOnly, }); -}; -export default fetchPoolsRoute; + // Map to the shared format (same shape as Meteora) + const pools = rawPools.map((pool: any) => ({ + address: pool.address, + name: `${pool.tokenA?.symbol || '?'}-${pool.tokenB?.symbol || '?'}`, + baseTokenAddress: pool.tokenMintA, + baseTokenSymbol: pool.tokenA?.symbol || '', + quoteTokenAddress: pool.tokenMintB, + quoteTokenSymbol: pool.tokenB?.symbol || '', + binStep: pool.tickSpacing, + baseFee: Number(pool.feeRate) / 10000, // Convert to percentage + price: Number(pool.price), + tvl: Number(pool.tvlUsdc) || 0, + apr: pool.feeApr?.day ? Number(pool.feeApr.day) * 100 : undefined, // Convert to percentage + apy: pool.totalApr?.day ? Number(pool.totalApr.day) * 100 : undefined, + volume24h: pool.volume?.day ? Number(pool.volume.day) : undefined, + fees24h: pool.fees?.day ? Number(pool.fees.day) : undefined, + })); + + return { + pools, + total: pools.length, // Orca API doesn't return total count + page: 1, + pageSize: limit, + }; +} diff --git a/src/connectors/orca/clmm-routes/index.ts b/src/connectors/orca/clmm-routes/index.ts deleted file mode 100644 index 4198fb2b05..0000000000 --- a/src/connectors/orca/clmm-routes/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { addLiquidityRoute } from './addLiquidity'; -import { closePositionRoute } from './closePosition'; -import { collectFeesRoute } from './collectFees'; -import { createPoolRoute } from './createPool'; -import { executeSwapRoute } from './executeSwap'; -import { fetchPoolsRoute } from './fetchPools'; -import { openPositionRoute } from './openPosition'; -import { poolInfoRoute } from './poolInfo'; -import { positionInfoRoute } from './positionInfo'; -import { positionsOwnedRoute } from './positionsOwned'; -import { quotePositionRoute } from './quotePosition'; -import { quoteSwapRoute } from './quoteSwap'; -import { removeLiquidityRoute } from './removeLiquidity'; - -export const orcaClmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(fetchPoolsRoute); - await fastify.register(poolInfoRoute); - await fastify.register(positionsOwnedRoute); - await fastify.register(positionInfoRoute); - await fastify.register(quotePositionRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(executeSwapRoute); - await fastify.register(createPoolRoute); - await fastify.register(openPositionRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(collectFeesRoute); - await fastify.register(closePositionRoute); -}; - -export default orcaClmmRoutes; diff --git a/src/connectors/orca/clmm-routes/openPosition.ts b/src/connectors/orca/clmm-routes/openPosition.ts index a55ad0aa4c..1b1c66fb25 100644 --- a/src/connectors/orca/clmm-routes/openPosition.ts +++ b/src/connectors/orca/clmm-routes/openPosition.ts @@ -15,22 +15,20 @@ import { priceToTickIndex, type IncreaseLiquidityQuote, } from '@orca-so/whirlpools-core'; -import { Static } from '@sinclair/typebox'; import { address, type Instruction } from '@solana/kit'; import { getAssociatedTokenAddressSync, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; import { Keypair, PublicKey } from '@solana/web3.js'; import { fetchAllMint } from '@solana-program/token-2022'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { OpenPositionResponse, OpenPositionResponseType } from '../../../schemas/clmm-schema'; +import { OpenPositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { getCurrentTransferFee } from '../orca.position'; import { buildOrcaTransaction, createOrcaAuthority, replaceOrcaInstructionAccounts } from '../orca.sdk'; import { extractInnerTransferAmounts } from '../orca.utils'; -import { OrcaClmmOpenPositionRequest } from '../schemas'; export async function openPosition( network: string, @@ -61,7 +59,7 @@ export async function openPosition( throw httpErrors.badRequest('Calculated tick indices are invalid (lower >= upper)'); } - const slippageBps = Math.round((slippagePct || 1) * 100); + const slippageBps = Math.round((slippagePct ?? OrcaConfig.config.slippagePct ?? 1) * 100); const baseAmount = BigInt(Math.floor((baseTokenAmount || 0) * 10 ** mintA.data.decimals)); const quoteAmount = BigInt(Math.floor((quoteTokenAmount || 0) * 10 ** mintB.data.decimals)); const shouldAddLiquidity = baseAmount > 0n || quoteAmount > 0n; @@ -141,7 +139,14 @@ export async function openPosition( const generated = await openPositionInstructionsWithTickBounds( rpc, whirlpool.address, - { tokenMaxA: liquidityQuote.tokenMaxA, tokenMaxB: liquidityQuote.tokenMaxB }, + // The estimates, not the quote's ceilings. increaseLiquidityQuote{A,B} already + // applied slippageBps to produce tokenMax*, and the builder applies + // slippageToleranceBps again below to derive the on-chain maximums — so passing + // tokenMax* here makes the ceiling the target and deposits slippagePct more than + // was asked for. A one-sided open showed it plainly: 1 USDC funded deposited + // 1.009999. A two-sided one hides it, because the pool ratio pins the deposit + // before the bound is reached. + { tokenMaxA: liquidityQuote.tokenEstA, tokenMaxB: liquidityQuote.tokenEstB }, lowerTickIndex, upperTickIndex, { @@ -218,10 +223,9 @@ export async function openPosition( const { signature, fee } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, [ positionMintKeypair, ]); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); let positionRent = 0; let baseTokenAmountAdded = liquidityQuote ? Number(liquidityQuote.tokenEstA) / 10 ** mintA.data.decimals : 0; @@ -274,50 +278,3 @@ export async function openPosition( }, }; } - -export const openPositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: OpenPositionResponseType; - }>( - '/open-position', - { - schema: { - description: 'Open a new Orca position', - tags: ['/connector/orca'], - body: OrcaClmmOpenPositionRequest, - response: { 200: OpenPositionResponse }, - }, - }, - async (request) => { - try { - const { - walletAddress, - poolAddress, - lowerPrice, - upperPrice, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - network, - } = request.body; - return await openPosition( - network, - walletAddress, - poolAddress, - lowerPrice, - upperPrice, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (error) { - logger.error(error); - if (error.statusCode) throw error; - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default openPositionRoute; diff --git a/src/connectors/orca/clmm-routes/poolInfo.ts b/src/connectors/orca/clmm-routes/poolInfo.ts index e194c47a50..965fd79168 100644 --- a/src/connectors/orca/clmm-routes/poolInfo.ts +++ b/src/connectors/orca/clmm-routes/poolInfo.ts @@ -1,20 +1,13 @@ import { sqrtPriceToPrice } from '@orca-so/whirlpools-core'; import { PublicKey } from '@solana/web3.js'; import { fetchAllMint } from '@solana-program/token-2022'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; import { PoolInfo } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; import { Orca } from '../orca'; import { computeOrcaBinDistribution } from '../orca.utils'; -import { - OrcaClmmGetPoolInfoRequest, - OrcaClmmGetPoolInfoRequestType, - OrcaPoolInfo, - OrcaPoolInfoSchema, -} from '../schemas'; - +import { OrcaPoolInfo } from '../schemas'; export async function getPoolInfo( fastify: FastifyInstance, network: string, @@ -100,36 +93,3 @@ export async function getPoolInfo( return poolInfo; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: OrcaClmmGetPoolInfoRequestType; - Reply: OrcaPoolInfo; - }>( - '/pool-info', - { - schema: { - description: 'Get pool information for a Orca pool', - tags: ['/connector/orca'], - querystring: OrcaClmmGetPoolInfoRequest, - response: { - 200: OrcaPoolInfoSchema, - }, - }, - }, - async (request) => { - try { - const { poolAddress, binCount = 0, network } = request.query; - return (await getPoolInfo(fastify, network, poolAddress, binCount)) as OrcaPoolInfo; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/orca/clmm-routes/positionInfo.ts b/src/connectors/orca/clmm-routes/positionInfo.ts index 1f3eb62827..c893a8e6c3 100644 --- a/src/connectors/orca/clmm-routes/positionInfo.ts +++ b/src/connectors/orca/clmm-routes/positionInfo.ts @@ -1,11 +1,9 @@ import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { PositionInfo, PositionInfoSchema, GetPositionInfoRequestType } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { Orca } from '../orca'; -import { OrcaClmmGetPositionInfoRequest } from '../schemas'; export async function getPositionInfo( fastify: FastifyInstance, @@ -41,37 +39,3 @@ export async function getPositionInfo( return positionInfo; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get details for a specific Orca position', - tags: ['/connector/orca'], - querystring: OrcaClmmGetPositionInfoRequest, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { positionAddress, walletAddress } = request.query; - const network = request.query.network; - return await getPositionInfo(fastify, network, positionAddress, walletAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/orca/clmm-routes/positionsOwned.ts b/src/connectors/orca/clmm-routes/positionsOwned.ts index c66e78bf53..e8ba8b4d52 100644 --- a/src/connectors/orca/clmm-routes/positionsOwned.ts +++ b/src/connectors/orca/clmm-routes/positionsOwned.ts @@ -1,14 +1,10 @@ -import { Type } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; -import { GetPositionsOwnedRequestType, PositionInfo, PositionInfoSchema } from '../../../schemas/clmm-schema'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; -import { OrcaClmmGetPositionsOwnedRequest } from '../schemas'; - -const INVALID_SOLANA_ADDRESS_MESSAGE = (address: string) => `Invalid Solana address: ${address}`; export async function getPositionsOwned( fastify: FastifyInstance, @@ -34,36 +30,3 @@ export async function getPositionsOwned( logger.info(`Found ${positions.length} Orca position(s) for wallet ${walletAddressToUse.slice(0, 8)}...`); return positions; } - -export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPositionsOwnedRequestType; - Reply: PositionInfo[]; - }>( - '/positions-owned', - { - schema: { - description: "Retrieve all positions owned by a user's wallet across Orca CLMM pools", - tags: ['/connector/orca'], - querystring: OrcaClmmGetPositionsOwnedRequest, - response: { - 200: Type.Array(PositionInfoSchema), - }, - }, - }, - async (request) => { - try { - const { network, walletAddress } = request.query; - return await getPositionsOwned(fastify, network, walletAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default positionsOwnedRoute; diff --git a/src/connectors/orca/clmm-routes/quotePosition.ts b/src/connectors/orca/clmm-routes/quotePosition.ts index 98a94e45c4..bd0ccb9787 100644 --- a/src/connectors/orca/clmm-routes/quotePosition.ts +++ b/src/connectors/orca/clmm-routes/quotePosition.ts @@ -1,12 +1,8 @@ -import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -import { QuotePositionResponseType, QuotePositionResponse } from '../../../schemas/clmm-schema'; +import { QuotePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { quotePosition as getQuotePosition } from '../orca.utils'; -import { OrcaClmmQuotePositionRequest } from '../schemas'; export async function quotePosition( network: string, @@ -15,7 +11,7 @@ export async function quotePosition( poolAddress: string, baseTokenAmount?: number, quoteTokenAmount?: number, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { const orca = await Orca.getInstance(network); @@ -46,51 +42,3 @@ export async function quotePosition( return quote; } - -export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: Static; - Reply: QuotePositionResponseType; - }>( - '/quote-position', - { - schema: { - description: 'Quote amounts for a new Orca CLMM position', - tags: ['/connector/orca'], - querystring: OrcaClmmQuotePositionRequest, - response: { - 200: QuotePositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.query; - - return await quotePosition( - network, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to quote position'); - } - }, - ); -}; - -export default quotePositionRoute; diff --git a/src/connectors/orca/clmm-routes/quoteSwap.ts b/src/connectors/orca/clmm-routes/quoteSwap.ts index e2c200d041..3a5ed2490e 100644 --- a/src/connectors/orca/clmm-routes/quoteSwap.ts +++ b/src/connectors/orca/clmm-routes/quoteSwap.ts @@ -1,12 +1,9 @@ -import { FastifyPluginAsync } from 'fastify'; - import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapResponseType, QuoteSwapResponse } from '../../../schemas/clmm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { getOrcaSwapQuote } from '../orca.utils'; -import { OrcaClmmQuoteSwapRequest, OrcaClmmQuoteSwapRequestType } from '../schemas'; export async function getRawSwapQuote( network: string, @@ -15,7 +12,7 @@ export async function getRawSwapQuote( amount: number, side: 'BUY' | 'SELL', poolAddress: string, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ) { const solana = await Solana.getInstance(network); const orca = await Orca.getInstance(network); @@ -50,7 +47,7 @@ async function formatSwapQuote( amount: number, side: 'BUY' | 'SELL', poolAddress: string, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { const quote = await getRawSwapQuote( network, @@ -76,86 +73,6 @@ async function formatSwapQuote( }; } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: OrcaClmmQuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Orca CLMM', - tags: ['/connector/orca'], - querystring: OrcaClmmQuoteSwapRequest, - response: { - 200: QuoteSwapResponse, - }, - }, - }, - async (request) => { - try { - const { network, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = request.query; - const networkUsed = network; - - // Validate essential parameters - if (!baseToken || !quoteToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, quoteToken, amount, and side are required'); - } - - const solana = await Solana.getInstance(networkUsed); - - let poolAddressToUse = poolAddress; - - // If poolAddress is not provided, look it up by token pair - if (!poolAddressToUse) { - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest(`Token not found: ${!baseTokenInfo ? baseToken : quoteToken}`); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'orca', - networkUsed, - 'clmm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Orca`, - ); - } - - poolAddressToUse = pool.address; - } - - return await formatSwapQuote( - networkUsed, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - poolAddressToUse, - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Resolves the counter ("quote") token for an Orca whirlpool given the base token. The standardized * swap wrappers take poolAddress + baseToken and derive the other side from the pool diff --git a/src/connectors/orca/clmm-routes/removeLiquidity.ts b/src/connectors/orca/clmm-routes/removeLiquidity.ts index 623b12e0a4..ca48806dd3 100644 --- a/src/connectors/orca/clmm-routes/removeLiquidity.ts +++ b/src/connectors/orca/clmm-routes/removeLiquidity.ts @@ -1,29 +1,27 @@ import { decreaseLiquidityInstructions } from '@orca-so/whirlpools'; import { fetchPosition, fetchWhirlpool } from '@orca-so/whirlpools-client'; -import { Static } from '@sinclair/typebox'; import { address } from '@solana/kit'; import { PublicKey } from '@solana/web3.js'; import { fetchAllMint } from '@solana-program/token-2022'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; -import { OrcaClmmRemoveLiquidityRequest } from '../schemas'; export async function removeLiquidity( network: string, walletAddress: string, positionAddress: string, - liquidityPct: number, - slippagePct: number, + percentageToRemove: number, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { - if (liquidityPct <= 0 || liquidityPct > 100) { - throw httpErrors.badRequest('liquidityPct must be between 0 and 100'); + if (percentageToRemove <= 0 || percentageToRemove > 100) { + throw httpErrors.badRequest('percentageToRemove must be between 0 and 100'); } const solana = await Solana.getInstance(network); @@ -33,7 +31,7 @@ export async function removeLiquidity( const whirlpool = await fetchWhirlpool(orca.solanaKitRpc, position.data.whirlpool); const [mintA, mintB] = await fetchAllMint(orca.solanaKitRpc, [whirlpool.data.tokenMintA, whirlpool.data.tokenMintB]); const liquidityAmount = BigInt( - new Decimal(position.data.liquidity.toString()).mul(liquidityPct).div(100).floor().toFixed(0), + new Decimal(position.data.liquidity.toString()).mul(percentageToRemove).div(100).floor().toFixed(0), ); if (liquidityAmount <= 0n || liquidityAmount > position.data.liquidity) { @@ -51,7 +49,7 @@ export async function removeLiquidity( }, ); logger.info( - `Removing ${liquidityPct}% liquidity, estimated: ` + + `Removing ${percentageToRemove}% liquidity, estimated: ` + `${(Number(result.quote.tokenEstA) / 10 ** mintA.data.decimals).toFixed(6)} tokenA, ` + `${(Number(result.quote.tokenEstB) / 10 ** mintB.data.decimals).toFixed(6)} tokenB`, ); @@ -76,38 +74,13 @@ export async function removeLiquidity( signature, status: 1, data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: position.data.whirlpool.toString(), fee, baseTokenAmountRemoved: Math.abs(balanceChanges[0]), quoteTokenAmountRemoved: Math.abs(balanceChanges[1]), }, }; } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from an Orca position', - tags: ['/connector/orca'], - body: OrcaClmmRemoveLiquidityRequest, - response: { 200: RemoveLiquidityResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, positionAddress, liquidityPct = 100, slippagePct = 1, network } = request.body; - return await removeLiquidity(network, walletAddress, positionAddress, liquidityPct, slippagePct); - } catch (error) { - logger.error(error); - if (error.statusCode) throw error; - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/orca/orca.routes.ts b/src/connectors/orca/orca.routes.ts deleted file mode 100644 index fd9fae7c94..0000000000 --- a/src/connectors/orca/orca.routes.ts +++ /dev/null @@ -1,27 +0,0 @@ -import sensible from '@fastify/sensible'; -import type { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { orcaClmmRoutes } from './clmm-routes'; - -// CLMM routes including swap endpoints -const orcaClmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/orca']; - } - }); - - await instance.register(orcaClmmRoutes); - }); -}; - -// Export the CLMM routes -export const orcaRoutes = { - clmm: orcaClmmRoutesWrapper, -}; - -export default orcaRoutes; diff --git a/src/connectors/orca/orca.ts b/src/connectors/orca/orca.ts index 444c94fa6d..ae7d5c354f 100644 --- a/src/connectors/orca/orca.ts +++ b/src/connectors/orca/orca.ts @@ -1,5 +1,5 @@ import { fetchPositionsForOwner, setNativeMintWrappingStrategy, type WhirlpoolDeployment } from '@orca-so/whirlpools'; -import { fetchWhirlpool, fetchPosition } from '@orca-so/whirlpools-client'; +import { fetchWhirlpool, fetchPosition, fetchMaybePosition } from '@orca-so/whirlpools-client'; import { address, createSolanaRpc, mainnet, devnet } from '@solana/kit'; import { PublicKey } from '@solana/web3.js'; @@ -369,12 +369,16 @@ export class Orca { throw httpErrors.badRequest(`Invalid position address: ${positionAddress}`); } - try { - const positionInfo = await getPositionDetails(this.solanaKitRpc, positionAddress, this.deployment); - return positionInfo; - } catch (error) { - logger.error('Error getting position info:', error); + // null means "the position account does not exist" — a definitive on-chain + // answer that callers (position-info 404, close reconciliation in + // Hummingbot's LP executor) treat as "position closed". Anything else must + // throw: swallowing a transient RPC failure here serves "position closed" + // for a network blip, and an executor acting on that abandons a live, + // funded position while reporting success. + const maybePosition = await fetchMaybePosition(this.solanaKitRpc, address(positionAddress)); + if (!maybePosition.exists) { return null; } + return await getPositionDetails(this.solanaKitRpc, positionAddress, this.deployment); } } diff --git a/src/connectors/orca/schemas.ts b/src/connectors/orca/schemas.ts index e6c69946ed..e5bc8d1c3b 100644 --- a/src/connectors/orca/schemas.ts +++ b/src/connectors/orca/schemas.ts @@ -1,22 +1,10 @@ import { Type, Static } from '@sinclair/typebox'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { PoolInfoSchema } from '../../schemas/clmm-schema'; -import { OrcaConfig } from './orca.config'; - // Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); // Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.01; -const BASE_TOKEN_AMOUNT = 0.01; -const QUOTE_TOKEN_AMOUNT = 2; -const LOWER_PRICE_BOUND = 200; -const UPPER_PRICE_BOUND = 300; -const CLMM_POOL_ADDRESS_EXAMPLE = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; // Orca-specific extension export const OrcaPoolInfoSchema = Type.Composite( @@ -34,562 +22,4 @@ export const OrcaPoolInfoSchema = Type.Composite( ); export type OrcaPoolInfo = Static; -// Orca-specific extensions for quote-swap -export const OrcaQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OrcaConfig.config.slippagePct, - examples: [OrcaConfig.config.slippagePct], - }), - ), - poolAddress: Type.Optional(Type.String()), -}); - -// Orca-specific extensions for quote-swap response -export const OrcaQuoteSwapResponse = Type.Object({ - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - exchangeRate: Type.Number(), - priceImpactPct: Type.Number(), - poolAddress: Type.String(), - fee: Type.Number(), - gasEstimate: Type.String(), - computeUnits: Type.Number(), -}); - -// Orca CLMM-specific extensions -export const OrcaClmmQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Orca CLMM pool address (optional - can be looked up from baseToken and quoteToken)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'The other token in the pair (optional - required if poolAddress not provided)', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OrcaConfig.config.slippagePct, - examples: [OrcaConfig.config.slippagePct], - }), - ), -}); - -// Export the type for QuoteSwapRequest -export type OrcaClmmQuoteSwapRequestType = Static; - -export const OrcaClmmExecuteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Orca CLMM pool address (optional - can be looked up from baseToken and quoteToken)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address (optional - required if poolAddress not provided)', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OrcaConfig.config.slippagePct, - examples: [OrcaConfig.config.slippagePct], - }), - ), -}); - -// Export the type for ExecuteSwapRequest -export type OrcaClmmExecuteSwapRequestType = Static; - -// Orca CLMM Open Position Request -export const OrcaClmmOpenPositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will open the position', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Orca CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OrcaConfig.config.slippagePct, - examples: [OrcaConfig.config.slippagePct], - }), - ), -}); - -// Orca CLMM Create Pool Request -export const OrcaClmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will create and initialize the pool', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - tickSpacing: Type.Integer({ - description: - 'Tick spacing (fee tier) for the new Whirlpool. A FeeTier account for this config+tickSpacing ' + - 'must already exist on-chain. Common Orca values: 1, 2, 8, 16, 64, 128, 256.', - minimum: 1, - examples: [64], - }), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. If omitted, the current market price is fetched from the ' + - 'unified swap router so the pool opens on-market. No position is opened; only the pool is created.', - examples: [QUOTE_TOKEN_AMOUNT / BASE_TOKEN_AMOUNT], - }), - ), -}); - -// Orca CLMM Add Liquidity Request -export const OrcaClmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will add liquidity', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OrcaConfig.config.slippagePct, - examples: [OrcaConfig.config.slippagePct], - }), - ), -}); - -// Orca CLMM Remove Liquidity Request -export const OrcaClmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will remove liquidity', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), - liquidityPct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - default: 100, - examples: [100], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: 1, - examples: [1], - }), - ), -}); - -// Orca CLMM Close Position Request -export const OrcaClmmClosePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will close the position', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), -}); - -// Orca CLMM Collect Fees Request -export const OrcaClmmCollectFeesRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will collect fees', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), -}); - -// Orca CLMM Fetch Pools Request -export const OrcaClmmFetchPoolsRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - limit: Type.Optional( - Type.Number({ - minimum: 1, - maximum: 100, - default: 50, - description: 'Maximum number of pools to return', - examples: [50], - }), - ), - query: Type.Optional( - Type.String({ - description: 'Search query to match pools by name, tokens, or address', - examples: ['SOL', 'USDC', 'SOL-USDC'], - }), - ), - sortBy: Type.Optional( - Type.String({ - description: 'Sort by field', - enum: ['volume', 'tvl', 'fees', 'rewards', 'yieldovertvl'], - default: 'volume', - }), - ), - sortDirection: Type.Optional( - Type.String({ - description: 'Sort direction', - enum: ['asc', 'desc'], - default: 'desc', - }), - ), - verifiedOnly: Type.Optional( - Type.Boolean({ - description: 'Only return pools with verified tokens', - default: false, - }), - ), -}); - -// Orca CLMM Get Pool Info Request -export const OrcaClmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Orca CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - // binCount inherited from base GetPoolInfoRequest semantics — declared - // explicitly here so the Orca network enum override stays a flat schema. - binCount: Type.Optional( - Type.Integer({ - description: - 'If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick). ' + - 'Default 0 — pool-info skips the extra getProgramAccounts call.', - default: 0, - minimum: 0, - maximum: 401, - }), - ), -}); -export type OrcaClmmGetPoolInfoRequestType = Static; - -// Orca CLMM Get Position Info Request -export const OrcaClmmGetPositionInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - positionAddress: Type.String({ - description: 'Position address', - examples: [''], - }), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), -}); - -// Orca CLMM Get Positions Owned Request -export const OrcaClmmGetPositionsOwnedRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address to check for positions', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), -}); - -// Orca CLMM Quote Position Request -export const OrcaClmmQuotePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...OrcaConfig.networks], - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Orca CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: OrcaConfig.config.slippagePct, - examples: [OrcaConfig.config.slippagePct], - }), - ), -}); - // Orca position data structure (from @orca-so/whirlpools) -const OrcaPositionDataSchema = Type.Object({ - discriminator: Type.Any(), // Uint8Array(8) - whirlpool: Type.String(), - positionMint: Type.String(), - liquidity: Type.Any(), // bigint - tickLowerIndex: Type.Number(), - tickUpperIndex: Type.Number(), - feeGrowthCheckpointA: Type.Any(), // bigint - feeOwedA: Type.Any(), // bigint - feeGrowthCheckpointB: Type.Any(), // bigint - feeOwedB: Type.Any(), // bigint - rewardInfos: Type.Array(Type.Any()), // Array of reward info objects -}); - -const OrcaPositionSchema = Type.Object({ - executable: Type.Boolean(), - lamports: Type.Any(), // bigint - programAddress: Type.String(), - space: Type.Any(), // bigint - address: Type.String(), - data: OrcaPositionDataSchema, - exists: Type.Boolean(), - tokenProgram: Type.String(), - isPositionBundle: Type.Boolean(), -}); - -export type OrcaPosition = Static; diff --git a/src/connectors/pancakeswap-sol/README.md b/src/connectors/pancakeswap-sol/README.md index d734a508c9..4bb57a756f 100644 --- a/src/connectors/pancakeswap-sol/README.md +++ b/src/connectors/pancakeswap-sol/README.md @@ -25,7 +25,7 @@ Key features: ### Read-Only Routes (Implemented) #### 1. Pool Info -- **Endpoint**: `GET /connectors/pancakeswap-sol/clmm/pool-info` +- **Endpoint**: `GET /trading/clmm/pool-info` (with `connector=pancakeswap-sol`) - **Description**: Fetch detailed information about a CLMM pool - **Parameters**: - `network`: Solana network (mainnet-beta or devnet) @@ -33,7 +33,7 @@ Key features: - **Returns**: Pool info including tokens, price, liquidity, fees, tick spacing, etc. #### 2. Position Info -- **Endpoint**: `GET /connectors/pancakeswap-sol/clmm/position-info` +- **Endpoint**: `GET /trading/clmm/position-info` (with `connector=pancakeswap-sol`) - **Description**: Fetch information about a specific position NFT - **Parameters**: - `network`: Solana network @@ -41,7 +41,7 @@ Key features: - **Returns**: Position details including price range, liquidity, fees earned, etc. #### 3. Positions Owned -- **Endpoint**: `GET /connectors/pancakeswap-sol/clmm/positions-owned` +- **Endpoint**: `GET /trading/clmm/positions-owned` (with `connector=pancakeswap-sol`) - **Description**: List all positions owned by a wallet in a specific pool - **Parameters**: - `network`: Solana network @@ -53,7 +53,7 @@ Key features: ### Swap Routes (Implemented) #### 4. Quote Swap -- **Endpoint**: `GET /connectors/pancakeswap-sol/clmm/quote-swap` +- **Endpoint**: `GET /trading/clmm/quote-swap` (with `connector=pancakeswap-sol`) - **Description**: Get swap quote for a token pair (simplified - uses spot price) - **Parameters**: - `network`: Solana network @@ -71,7 +71,7 @@ Key features: - Suitable for small trades where price impact is minimal #### 5. Execute Swap -- **Endpoint**: `POST /connectors/pancakeswap-sol/clmm/execute-swap` +- **Endpoint**: `POST /trading/clmm/execute-swap` (with `connector=pancakeswap-sol`) - **Description**: Execute a swap on PancakeSwap Solana CLMM - **Parameters**: - `network`: Solana network @@ -92,7 +92,7 @@ Key features: ### Position Management Routes (Implemented) #### 6. Add Liquidity -- **Endpoint**: `POST /connectors/pancakeswap-sol/clmm/add-liquidity` +- **Endpoint**: `POST /trading/clmm/add` (with `connector=pancakeswap-sol`) - **Description**: Add liquidity to an existing position - **Parameters**: - `network`: Solana network @@ -104,7 +104,7 @@ Key features: - **Implementation**: Uses increase_liquidity_v2 instruction with manual building #### 7. Remove Liquidity -- **Endpoint**: `POST /connectors/pancakeswap-sol/clmm/remove-liquidity` +- **Endpoint**: `POST /trading/clmm/remove` (with `connector=pancakeswap-sol`) - **Description**: Remove liquidity from a position by percentage - **Parameters**: - `network`: Solana network @@ -115,7 +115,7 @@ Key features: - **Implementation**: Uses decrease_liquidity_v2 instruction with tick array calculation #### 8. Collect Fees -- **Endpoint**: `POST /connectors/pancakeswap-sol/clmm/collect-fees` +- **Endpoint**: `POST /trading/clmm/collect-fees` (with `connector=pancakeswap-sol`) - **Description**: Collect accumulated fees from a position - **Parameters**: - `network`: Solana network @@ -125,7 +125,7 @@ Key features: - **Implementation**: Uses the clever approach of removing 1% liquidity to collect fees #### 9. Close Position -- **Endpoint**: `POST /connectors/pancakeswap-sol/clmm/close-position` +- **Endpoint**: `POST /trading/clmm/close` (with `connector=pancakeswap-sol`) - **Description**: Close an empty position (must have zero liquidity) - **Parameters**: - `network`: Solana network @@ -135,7 +135,7 @@ Key features: - **Implementation**: Uses close_position instruction, requires position to be emptied first #### 10. Quote Position -- **Endpoint**: `GET /connectors/pancakeswap-sol/clmm/quote-position` +- **Endpoint**: `GET /trading/clmm/quote-liquidity` (with `connector=pancakeswap-sol`) - **Description**: Quote token amounts for opening a position (simplified) - **Parameters**: - `network`: Solana network @@ -148,7 +148,7 @@ Key features: - **Note**: Simplified version using spot price, not full tick math #### 11. Open Position -- **Endpoint**: `POST /connectors/pancakeswap-sol/clmm/open-position` +- **Endpoint**: `POST /trading/clmm/open` (with `connector=pancakeswap-sol`) - **Description**: Open a new CLMM position with Token2022 NFT and metadata - **Parameters**: - `network`: Solana network @@ -171,7 +171,7 @@ Key features: All essential CLMM routes have been implemented: - ✅ **Pool/Position Info** (3 routes): pool-info, position-info, positions-owned - ✅ **Swap Operations** (2 routes): quote-swap, execute-swap -- ✅ **Position Management** (6 routes): quote-position, open-position, add-liquidity, remove-liquidity, collect-fees, close-position +- ✅ **Position Management** (6 routes): quote-liquidity, open, add, remove, collect-fees, close **Total: 11 routes** providing complete CLMM functionality without SDK dependency. @@ -179,35 +179,36 @@ All essential CLMM routes have been implemented: ### Get Pool Info ```bash -curl "http://localhost:15888/connectors/pancakeswap-sol/clmm/pool-info?network=mainnet-beta&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ" +curl "http://localhost:15888/trading/clmm/pool-info?chainNetwork=solana-mainnet-beta&connector=pancakeswap-sol&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ" ``` ### Get Position Info ```bash -curl "http://localhost:15888/connectors/pancakeswap-sol/clmm/position-info?network=mainnet-beta&positionAddress=F1xRqqbWdg3vdMEsn9YjRU7RnFVn67MZhDVXrWoobii5" +curl "http://localhost:15888/trading/clmm/position-info?chainNetwork=solana-mainnet-beta&connector=pancakeswap-sol&positionAddress=F1xRqqbWdg3vdMEsn9YjRU7RnFVn67MZhDVXrWoobii5" ``` ### Get Positions Owned ```bash -curl "http://localhost:15888/connectors/pancakeswap-sol/clmm/positions-owned?network=mainnet-beta&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ&walletAddress=" +curl "http://localhost:15888/trading/clmm/positions-owned?chainNetwork=solana-mainnet-beta&connector=pancakeswap-sol&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ&walletAddress=" ``` ### Quote Swap ```bash # SELL 0.01 SOL for USDC -curl "http://localhost:15888/connectors/pancakeswap-sol/clmm/quote-swap?network=mainnet-beta&baseToken=SOL"eToken=USDC&amount=0.01&side=SELL&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ" +curl "http://localhost:15888/trading/clmm/quote-swap?chainNetwork=solana-mainnet-beta&connector=pancakeswap-sol&baseToken=SOL"eToken=USDC&amount=0.01&side=SELL&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ" # BUY 0.01 SOL with USDC -curl "http://localhost:15888/connectors/pancakeswap-sol/clmm/quote-swap?network=mainnet-beta&baseToken=SOL"eToken=USDC&amount=0.01&side=BUY&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ" +curl "http://localhost:15888/trading/clmm/quote-swap?chainNetwork=solana-mainnet-beta&connector=pancakeswap-sol&baseToken=SOL"eToken=USDC&amount=0.01&side=BUY&poolAddress=DJNtGuBGEQiUCWE8F981M2C3ZghZt2XLD8f2sQdZ6rsZ" ``` ### Execute Swap ```bash # SELL 0.01 SOL for USDC -curl -X POST "http://localhost:15888/connectors/pancakeswap-sol/clmm/execute-swap" \ +curl -X POST "http://localhost:15888/trading/clmm/execute-swap" \ -H "Content-Type: application/json" \ -d '{ - "network": "mainnet-beta", + "chainNetwork": "solana-mainnet-beta", + "connector": "pancakeswap-sol", "walletAddress": "", "baseToken": "SOL", "quoteToken": "USDC", diff --git a/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts b/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts index e9c881a774..1573541c39 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts @@ -1,16 +1,14 @@ -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas/clmm-schema'; +import { accountLifecycleSol, liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { AddLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapSol } from '../pancakeswap-sol'; import { PancakeswapSolConfig } from '../pancakeswap-sol.config'; import { buildAddLiquidityTransaction } from '../pancakeswap-sol.transactions'; -import { PancakeswapSolClmmAddLiquidityRequest } from '../schemas'; import { quotePosition } from './quotePosition'; @@ -111,78 +109,39 @@ export async function addLiquidity( quoteToken.address, ]); - const baseTokenChange = balanceChanges[0]; - const quoteTokenChange = balanceChanges[1]; + // Adding to a position can still create accounts — a wrapped-SOL account the wallet + // did not have, a tick array this range is first to touch — and their rent rides on + // the native side of the balance change without being liquidity. + const { opened } = accountLifecycleSol(txData); + const baseTokenChange = liquidityWithoutRent(balanceChanges[0], new PublicKey(baseToken.address), opened); + const quoteTokenChange = liquidityWithoutRent(balanceChanges[1], new PublicKey(quoteToken.address), opened); logger.info(`Liquidity added successfully. Signature: ${signature}`); logger.info( - `Added ${Math.abs(baseTokenChange).toFixed(4)} ${baseToken.symbol}, ${Math.abs(quoteTokenChange).toFixed(4)} ${quoteToken.symbol}`, + `Added ${baseTokenChange.toFixed(4)} ${baseToken.symbol}, ${quoteTokenChange.toFixed(4)} ${quoteToken.symbol}`, ); return { signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: positionInfo.poolAddress, fee: totalFee / 1e9, - baseTokenAmountAdded: Math.abs(baseTokenChange), - quoteTokenAmountAdded: Math.abs(quoteTokenChange), + baseTokenAmountAdded: baseTokenChange, + quoteTokenAmountAdded: quoteTokenChange, }, }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING }; } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to an existing PancakeSwap Solana CLMM position', - tags: ['/connector/pancakeswap-sol'], - body: PancakeswapSolClmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - walletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.body; - - return await addLiquidity( - network, - walletAddress!, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e: any) { - logger.error('Add liquidity error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to add liquidity'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts b/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts index a945fc6bb6..ae78d18005 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts @@ -1,17 +1,19 @@ -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ClosePositionResponse, ClosePositionResponseType } from '../../../schemas/clmm-schema'; +import { + accountLifecycleSol, + liquidityWithoutRent, + transfersByProgramInstruction, +} from '../../../chains/solana/solana.utils'; +import { ClosePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapSol, PANCAKESWAP_CLMM_PROGRAM_ID } from '../pancakeswap-sol'; import { buildDecreaseLiquidityV2Instruction, buildClosePositionInstruction } from '../pancakeswap-sol.instructions'; import { parsePositionData } from '../pancakeswap-sol.parser'; -import { buildTransactionWithInstructions } from '../pancakeswap-sol.transactions'; -import { PancakeswapSolClmmClosePositionRequest } from '../schemas'; +import { buildTransactionWithInstructions, buildUnwrapSolInstructions } from '../pancakeswap-sol.transactions'; export async function closePosition( network: string, @@ -66,8 +68,25 @@ export async function closePosition( // Build transaction with both instructions (like successful manual transaction) const instructions = []; - // 1. If position has liquidity, remove it all first + // 1. Collect the fees on their own, THEN remove the liquidity. + // + // This program moves a position's fees and its principal in the same + // `decrease_liquidity_v2` transfer, so a single instruction leaves the two + // inseparable — which is why this route reported fees of 0 and a principal that + // silently contained them. A zero-liquidity decrease collects the fees and touches + // nothing else (it is exactly what the collect-fees route does), so the two land in + // different top-level instructions and the transaction says which is which. if (hasLiquidity) { + const collectFeesIx = await buildDecreaseLiquidityV2Instruction( + solana, + positionNftMint, + walletPubkey, + new BN(0), // liquidity: fees only + new BN(0), // amount0Min + new BN(0), // amount1Min + ); + instructions.push(collectFeesIx); + const removeLiquidityIx = await buildDecreaseLiquidityV2Instruction( solana, positionNftMint, @@ -83,6 +102,11 @@ export async function closePosition( const closePositionIx = await buildClosePositionInstruction(solana, positionNftMint, walletPubkey); instructions.push(closePositionIx); + // 3. Unwrap what the withdrawal paid out in WSOL. Without this the SOL never reaches + // the native balance, and the only thing that moves it is the rent — which is exactly + // how this route came to report the rent as the liquidity withdrawn. + instructions.push(...buildUnwrapSolInstructions(solana, walletPubkey, [baseToken.address, quoteToken.address])); + // Build complete transaction const transaction = await buildTransactionWithInstructions( solana, @@ -102,72 +126,65 @@ export async function closePosition( const totalFee = txData.meta.fee; // Extract balance changes - const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + const { balanceChanges, txDetails } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ baseToken.address, quoteToken.address, ]); - const baseTokenChange = balanceChanges[0]; - const quoteTokenChange = balanceChanges[1]; + // Closing gives back the rent of every account that closed — the position, its NFT + // account, the tick array if this was the last position in it, and the wrapped-SOL + // account the unwrap above closes. All of it arrives in the same native balance + // change as the withdrawal, and none of it is liquidity. + const { closed, rentRefunded } = accountLifecycleSol(txData); + const baseTokenChange = liquidityWithoutRent(balanceChanges[0], new PublicKey(baseToken.address), closed); + const quoteTokenChange = liquidityWithoutRent(balanceChanges[1], new PublicKey(quoteToken.address), closed); + + // The fee-collecting instruction is the first of this program's instructions in the + // transaction, so its transfers are the fees and nothing else. Taken from the + // transaction rather than from a balance change, which cannot separate them. + // + // An unreadable transaction leaves this at zero and the amounts whole, which is what + // this route did for every close before now — a known shape, not a new silence. + const [collected = [0, 0]] = transfersByProgramInstruction(txDetails, PANCAKESWAP_CLMM_PROGRAM_ID.toBase58(), [ + baseToken.address, + quoteToken.address, + ]); + const baseFeeCollected = hasLiquidity ? collected[0] : 0; + const quoteFeeCollected = hasLiquidity ? collected[1] : 0; logger.info(`Position closed successfully. Signature: ${signature}`); logger.info( - `Removed ${Math.abs(baseTokenChange).toFixed(4)} ${baseToken.symbol}, ${Math.abs(quoteTokenChange).toFixed(4)} ${quoteToken.symbol}`, + `Removed ${baseTokenChange.toFixed(4)} ${baseToken.symbol}, ${quoteTokenChange.toFixed(4)} ${quoteToken.symbol}`, ); return { signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: positionInfo.poolAddress, fee: totalFee / 1e9, - positionRentRefunded: 0, // Position rent refund (simplified) - baseTokenAmountRemoved: Math.abs(baseTokenChange), - quoteTokenAmountRemoved: Math.abs(quoteTokenChange), - baseFeeAmountCollected: 0, // Included in balance changes - quoteFeeAmountCollected: 0, // Included in balance changes + positionRentRefunded: rentRefunded, + // Principal is what came back less what the fee instruction paid out. Both + // arrive in the same balance change, so the subtraction is what keeps fee + // income out of the position's returned capital. Clamped at zero rather than + // publishing a negative quantity of tokens if the two measures ever disagree. + baseTokenAmountRemoved: Math.max(0, baseTokenChange - baseFeeCollected), + quoteTokenAmountRemoved: Math.max(0, quoteTokenChange - quoteFeeCollected), + baseFeeAmountCollected: baseFeeCollected, + quoteFeeAmountCollected: quoteFeeCollected, }, }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING }; } - -export const closePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: ClosePositionResponseType; - }>( - '/close-position', - { - schema: { - description: 'Close a PancakeSwap Solana CLMM position and remove all liquidity and fees if present', - tags: ['/connector/pancakeswap-sol'], - body: PancakeswapSolClmmClosePositionRequest, - response: { - 200: ClosePositionResponse, - }, - }, - }, - async (request) => { - try { - const { network = 'mainnet-beta', walletAddress, positionAddress } = request.body; - - return await closePosition(network, walletAddress!, positionAddress); - } catch (e: any) { - logger.error('Close position error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to close position'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default closePositionRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts b/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts index b8bd781e1c..b9bddc4ffd 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts @@ -1,84 +1,114 @@ -import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; -import { CollectFeesResponse, CollectFeesResponseType } from '../../../schemas/clmm-schema'; +import { Solana } from '../../../chains/solana/solana'; +import { accountLifecycleSol, liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { CollectFeesResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { PancakeswapSolClmmCollectFeesRequest } from '../schemas'; - -import { removeLiquidity } from './removeLiquidity'; +import { PancakeswapSol } from '../pancakeswap-sol'; +import { buildRemoveLiquidityTransaction } from '../pancakeswap-sol.transactions'; +/** + * Collect accumulated fees from a position WITHOUT touching its liquidity. + * + * The PancakeSwap Solana CLMM program (a Raydium CLMM fork) has no owner-facing + * "collect fees" instruction — like Raydium, fees (and rewards) owed to a position are + * transferred by `decrease_liquidity_v2`. Calling it with `liquidity = 0` collects the + * owed fees while leaving the position's liquidity intact. + */ async function collectFees( network: string, walletAddress: string, positionAddress: string, ): Promise { - logger.info(`Collecting fees from position ${positionAddress} by removing 1% liquidity`); + const solana = await Solana.getInstance(network); + const pancakeswapSol = await PancakeswapSol.getInstance(network); + + const positionInfo = await pancakeswapSol.getPositionInfo(positionAddress); + if (!positionInfo) { + throw httpErrors.notFound(`Position not found: ${positionAddress}`); + } + + const baseToken = await solana.getToken(positionInfo.baseTokenAddress); + const quoteToken = await solana.getToken(positionInfo.quoteTokenAddress); + if (!baseToken || !quoteToken) { + throw httpErrors.notFound('Token information not found'); + } + + logger.info(`Collecting fees from position ${positionAddress} via zero-liquidity decrease`); + + const wallet = await solana.getWallet(walletAddress); + const walletPubkey = new PublicKey(walletAddress); + const positionNftMint = new PublicKey(positionAddress); + + // Get priority fee + const priorityFeeInLamports = await solana.estimateGasPrice(); + const priorityFeePerCU = Math.floor(priorityFeeInLamports * 1e6); + + // decrease_liquidity_v2 with liquidity = 0 transfers the owed fees (and rewards) + // without removing any liquidity. + const transaction = await buildRemoveLiquidityTransaction( + solana, + positionNftMint, + walletPubkey, + new BN(0), // liquidity: collect fees only + new BN(0), // amount0Min + new BN(0), // amount1Min + [baseToken.address, quoteToken.address], // unwrap a native-side fee rather than leaving it WSOL + 600000, // Compute units + priorityFeePerCU, + ); + + // Sign and send + transaction.sign([wallet]); + await solana.simulateWithErrorHandling(transaction); - // Use the clever Raydium approach: remove 1% of liquidity to collect fees - // This withdraws a tiny amount of liquidity + all accumulated fees - const removeLiquidityResponse = await removeLiquidity(network, walletAddress, positionAddress, 1); + const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction); + + if (confirmed && txData) { + const totalFee = txData.meta.fee; + + // No liquidity was removed, so the position tokens received are exactly the fees. + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + baseToken.address, + quoteToken.address, + ]); + + // Unwrapping closes the wrapped-SOL account, so its rent comes back in the same + // native balance change as the fee. Rent is not fee income. + const { closed } = accountLifecycleSol(txData); + const baseFeeCollected = liquidityWithoutRent(balanceChanges[0], new PublicKey(baseToken.address), closed); + const quoteFeeCollected = liquidityWithoutRent(balanceChanges[1], new PublicKey(quoteToken.address), closed); + + logger.info( + `Fees collected from position ${positionAddress}: ${baseFeeCollected.toFixed(6)} ${baseToken.symbol}, ` + + `${quoteFeeCollected.toFixed(6)} ${quoteToken.symbol}`, + ); - if (removeLiquidityResponse.status !== 1 || !removeLiquidityResponse.data) { return { - signature: removeLiquidityResponse.signature, - status: removeLiquidityResponse.status, + signature, + status: 1, // CONFIRMED + data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: positionInfo.poolAddress, + fee: totalFee / 1e9, + baseFeeAmountCollected: baseFeeCollected, + quoteFeeAmountCollected: quoteFeeCollected, + }, }; } - // The fees are included in the amounts removed - // Since we only removed 1%, most of the tokens received are fees - const baseFeeCollected = removeLiquidityResponse.data.baseTokenAmountRemoved; - const quoteFeeCollected = removeLiquidityResponse.data.quoteTokenAmountRemoved; - - logger.info(`Fees collected. Base: ${baseFeeCollected}, Quote: ${quoteFeeCollected}`); + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); return { - signature: removeLiquidityResponse.signature, - status: 1, // CONFIRMED - data: { - fee: removeLiquidityResponse.data.fee, - baseFeeAmountCollected: baseFeeCollected, - quoteFeeAmountCollected: quoteFeeCollected, - }, + signature, + status: 0, // PENDING }; } export { collectFees }; - -export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: CollectFeesResponseType; - }>( - '/collect-fees', - { - schema: { - description: 'Collect accumulated fees from a PancakeSwap Solana CLMM position (removes 1% liquidity)', - tags: ['/connector/pancakeswap-sol'], - body: PancakeswapSolClmmCollectFeesRequest, - response: { - 200: CollectFeesResponse, - }, - }, - }, - async (request) => { - try { - const { network = 'mainnet-beta', walletAddress, positionAddress } = request.body; - - return await collectFees(network, walletAddress!, positionAddress); - } catch (e: any) { - logger.error('Collect fees error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to collect fees'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default collectFeesRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts b/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts index 6660c7eb5f..db8d167acb 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts @@ -1,11 +1,9 @@ -import { Static } from '@sinclair/typebox'; import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getMint } from '@solana/spl-token'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -13,7 +11,6 @@ import { PancakeswapSol, PANCAKESWAP_CLMM_PROGRAM_ID } from '../pancakeswap-sol' import { buildCreatePoolInstruction } from '../pancakeswap-sol.instructions'; import { priceToSqrtPriceX64 } from '../pancakeswap-sol.math'; import { buildTransactionWithInstructions } from '../pancakeswap-sol.transactions'; -import { PancakeswapSolClmmCreatePoolRequest } from '../schemas'; /** Lexicographic byte comparison (mirrors Buffer.compare) for canonical mint ordering. */ function compareBytes(a: Buffer, b: Buffer): number { @@ -51,10 +48,10 @@ async function getMintProgram(solana: Solana, mint: PublicKey): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + quote = await getSwapQuote(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + @@ -67,12 +64,44 @@ async function fetchMarketPrice(network: string, baseToken: string, quoteToken: return quote.amountOut / quote.amountIn; // quote token per base token } +/** + * Derive the amm_config PDA for a fee-config index. The program (a Raydium CLMM + * fork) seeds it with ["amm_config", index] — index encoded big-endian, the same + * Raydium convention this connector's tick_array PDAs use. The little-endian + * (plain Anchor/borsh) form is tried as a fallback before failing. + */ +async function resolveAmmConfigByIndex(solana: Solana, ammConfigIndex: number): Promise { + if (!Number.isInteger(ammConfigIndex) || ammConfigIndex < 0 || ammConfigIndex > 0xffff) { + throw httpErrors.badRequest(`ammConfigIndex must be an integer in [0, 65535], got ${ammConfigIndex}`); + } + const candidates: PublicKey[] = []; + for (const endian of ['BE', 'LE'] as const) { + const indexBuffer = Buffer.alloc(2); + if (endian === 'BE') indexBuffer.writeUInt16BE(ammConfigIndex, 0); + else indexBuffer.writeUInt16LE(ammConfigIndex, 0); + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from('amm_config'), indexBuffer], + PANCAKESWAP_CLMM_PROGRAM_ID, + ); + candidates.push(pda); + } + for (const pda of candidates) { + const info = await solana.connection.getAccountInfo(pda); + if (info && info.owner.equals(PANCAKESWAP_CLMM_PROGRAM_ID)) { + return pda; + } + } + throw httpErrors.badRequest( + `No amm_config account found on-chain for index ${ammConfigIndex} ` + + `(tried ${candidates.map((c) => c.toBase58()).join(', ')})`, + ); +} + /** * Create and initialize (but do NOT seed a position for) a PancakeSwap Solana CLMM pool. * - * @param ammConfig Base58 address of an existing on-chain amm_config account for the desired fee tier. - * Required — there is no API to enumerate amm_config accounts, so the caller supplies - * the config for the fee tier they want (mirrors Meteora DAMM v2 configAddress). + * @param ammConfigIndex Fee-config index; resolves to the program's amm_config PDA + * (["amm_config", index]) and is validated on-chain. Default 0. */ export async function createPool( network: string, @@ -80,31 +109,13 @@ export async function createPool( baseToken: string, quoteToken: string, initialPrice?: number, - ammConfig?: string, + ammConfigIndex: number = 0, ): Promise { const solana = await Solana.getInstance(network); // Ensure the connector singleton is initialized (mirrors the other pancakeswap-sol routes). await PancakeswapSol.getInstance(network); - // Validate the required amm_config address and confirm it exists on-chain. - if (!ammConfig) { - throw httpErrors.badRequest('ammConfig is required: pass the address of an existing on-chain amm_config account'); - } - let ammConfigPubkey: PublicKey; - try { - ammConfigPubkey = new PublicKey(ammConfig); - } catch { - throw httpErrors.badRequest(sanitizeErrorMessage('Invalid ammConfig address: {}', ammConfig)); - } - const ammConfigInfo = await solana.connection.getAccountInfo(ammConfigPubkey); - if (!ammConfigInfo) { - throw httpErrors.badRequest(`amm_config account not found: ${ammConfigPubkey.toBase58()}`); - } - if (!ammConfigInfo.owner.equals(PANCAKESWAP_CLMM_PROGRAM_ID)) { - throw httpErrors.badRequest( - `amm_config ${ammConfigPubkey.toBase58()} is not owned by the PancakeSwap CLMM program`, - ); - } + const ammConfigPubkey = await resolveAmmConfigByIndex(solana, ammConfigIndex); // Resolve mints, decimals and token programs from authoritative on-chain data. const baseMint = await resolveMint(solana, baseToken); @@ -205,51 +216,13 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - // Pool created + initialized only — no liquidity/position seeded. - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: - 'Create and initialize a new PancakeSwap Solana CLMM pool at an initial price. Does not open or seed a position.', - tags: ['/connector/pancakeswap-sol'], - body: PancakeswapSolClmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - walletAddress, - baseToken, - quoteToken, - initialPrice, - ammConfig, - } = request.body; - return await createPool(network, walletAddress!, baseToken, quoteToken, initialPrice, ammConfig); - } catch (e: any) { - logger.error('Create pool error:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError(e.message || 'Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts b/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts index a291385bbe..87836e8a3c 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts @@ -1,16 +1,12 @@ -import { Static } from '@sinclair/typebox'; -import { PublicKey, VersionedTransaction } from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ExecuteSwapResponse, ExecuteSwapResponseType } from '../../../schemas/clmm-schema'; +import { ExecuteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapSol } from '../pancakeswap-sol'; -import { MIN_SQRT_PRICE_X64, MAX_SQRT_PRICE_X64 } from '../pancakeswap-sol.parser'; import { buildSwapTransaction } from '../pancakeswap-sol.transactions'; -import { PancakeswapSolClmmExecuteSwapRequest, PancakeswapSolClmmExecuteSwapRequestType } from '../schemas'; /** * Execute a swap on PancakeSwap Solana CLMM @@ -63,7 +59,6 @@ export async function executeSwap( // Validate pool contains the requested tokens const poolTokens = new Set([poolInfo.baseTokenAddress, poolInfo.quoteTokenAddress]); - const requestedTokens = new Set([baseToken.address, quoteToken.address]); if (!poolTokens.has(baseToken.address) || !poolTokens.has(quoteToken.address)) { throw httpErrors.badRequest( @@ -74,8 +69,6 @@ export async function executeSwap( } // Determine if baseToken matches pool's base or quote - const isBaseTokenFirst = poolInfo.baseTokenAddress === baseToken.address; - const currentPrice = isBaseTokenFirst ? poolInfo.price : 1 / poolInfo.price; logger.info( `Token addresses - base: ${baseToken.address}, quote: ${quoteToken.address}, pool base: ${poolInfo.baseTokenAddress}, pool quote: ${poolInfo.quoteTokenAddress}`, @@ -165,7 +158,6 @@ export async function executeSwap( walletAddress, baseToken, quoteToken, - totalFee, ); return { @@ -179,89 +171,17 @@ export async function executeSwap( fee: totalFee / 1e9, baseTokenBalanceChange: baseTokenChange, quoteTokenBalanceChange: quoteTokenChange, + slippagePct: quote.slippagePct, }, }; } else { - // Transaction pending + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING }; } } - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: PancakeswapSolClmmExecuteSwapRequestType; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on PancakeSwap Solana CLMM', - tags: ['/connector/pancakeswap-sol'], - body: PancakeswapSolClmmExecuteSwapRequest, - response: { 200: ExecuteSwapResponse }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - walletAddress, - baseToken, - quoteToken, - amount, - side, - poolAddress, - slippagePct, - } = request.body; - - // executeSwap is standardized to require poolAddress; resolve it from the pair when absent. - let poolAddressToUse = poolAddress; - if (!poolAddressToUse) { - const solana = await Solana.getInstance(network); - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest(`Token not found: ${!baseTokenInfo ? baseToken : quoteToken}`); - } - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool( - 'pancakeswap-sol', - network, - 'clmm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - if (!pool) { - throw httpErrors.notFound(`No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol}`); - } - poolAddressToUse = pool.address; - } - - return await executeSwap( - network, - walletAddress!, - poolAddressToUse, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - } catch (e: any) { - logger.error('Execute swap error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to execute swap'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/index.ts b/src/connectors/pancakeswap-sol/clmm-routes/index.ts deleted file mode 100644 index b25ba188fd..0000000000 --- a/src/connectors/pancakeswap-sol/clmm-routes/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import addLiquidityRoute from './addLiquidity'; -import closePositionRoute from './closePosition'; -import collectFeesRoute from './collectFees'; -import createPoolRoute from './createPool'; -import executeSwapRoute from './executeSwap'; -import openPositionRoute from './openPosition'; -import poolInfoRoute from './poolInfo'; -import positionInfoRoute from './positionInfo'; -import positionsOwnedRoute from './positionsOwned'; -import quotePositionRoute from './quotePosition'; -import quoteSwapRoute from './quoteSwap'; -import removeLiquidityRoute from './removeLiquidity'; - -export const pancakeswapSolClmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionInfoRoute); - await fastify.register(positionsOwnedRoute); - await fastify.register(quotePositionRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(executeSwapRoute); - await fastify.register(createPoolRoute); - await fastify.register(openPositionRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(collectFeesRoute); - await fastify.register(closePositionRoute); -}; - -export default pancakeswapSolClmmRoutes; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts b/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts index 699a811ced..4883bdaf36 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts @@ -1,17 +1,15 @@ -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { OpenPositionResponse, OpenPositionResponseType } from '../../../schemas/clmm-schema'; +import { accountLifecycleSol, liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { OpenPositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapSol } from '../pancakeswap-sol'; import { PancakeswapSolConfig } from '../pancakeswap-sol.config'; import { priceToTick, roundTickToSpacing, parsePoolTickSpacing } from '../pancakeswap-sol.parser'; import { buildOpenPositionTransaction } from '../pancakeswap-sol.transactions'; -import { PancakeswapSolClmmOpenPositionRequest } from '../schemas'; import { quotePosition } from './quotePosition'; @@ -105,17 +103,22 @@ export async function openPosition( ); logger.info(`Quote Max: base=${quote.baseTokenAmountMax}, quote=${quote.quoteTokenAmountMax}`); - // Use max amounts from quote - slippage already applied in quotePosition + // The ceilings — slippage already applied in quotePosition — and, separately, the + // liquidity to open. Sizing the position from the ceiling is what GW-28 was: the + // program would compute the deposit that liquidity requires, round it up in the + // pool's favour, and assert the result against the very number it started from, so a + // one-unit rounding failed the open and a wider slippagePct only bought a larger + // deposit. The quote already computes the liquidity from the amounts the caller asked + // for, which is what the add route has always sent. const amount0Max = new BN((quote.baseTokenAmountMax * 10 ** baseToken.decimals).toFixed(0)); const amount1Max = new BN((quote.quoteTokenAmountMax * 10 ** quoteToken.decimals).toFixed(0)); + const liquidity = new BN(quote.liquidity); logger.info(`Amounts with slippage (${slippagePct ?? PancakeswapSolConfig.config.slippagePct}%):`); logger.info(` amount0Max: ${amount0Max.toString()} (${baseToken.symbol})`); logger.info(` amount1Max: ${amount1Max.toString()} (${quoteToken.symbol})`); - // Determine base flag - const baseFlag = quote.baseLimited; - logger.info(`Base Flag: ${baseFlag} (${baseFlag ? 'amount0' : 'amount1'} is base)`); + logger.info(`Liquidity: ${liquidity.toString()} (from the quoted amounts, base-limited=${quote.baseLimited})`); // Get priority fee const priorityFeeInLamports = await solana.estimateGasPrice(); @@ -131,7 +134,8 @@ export async function openPosition( amount0Max, amount1Max, true, // withMetadata - create NFT with metadata - baseFlag, + null, // let the maxes be ceilings; the liquidity below is what sizes the position + liquidity, 800000, priorityFeePerCU, ); @@ -152,12 +156,17 @@ export async function openPosition( quoteToken.address, ]); - const baseTokenChange = balanceChanges[0]; - const quoteTokenChange = balanceChanges[1]; + // Opening locks rent in five accounts here — the position, its NFT account, the + // wrapped-SOL account, the shared protocol position, and any tick array this range is + // first to touch. On a position this size that rent is larger than the deposit it is + // attached to, so reporting the wallet delta made the position read 2.4x its size. + const { opened, rentLocked } = accountLifecycleSol(txData); + const baseTokenChange = liquidityWithoutRent(balanceChanges[0], new PublicKey(baseToken.address), opened); + const quoteTokenChange = liquidityWithoutRent(balanceChanges[1], new PublicKey(quoteToken.address), opened); logger.info(`Position opened successfully. NFT Mint: ${positionNftMint.publicKey.toString()}`); logger.info( - `Added ${Math.abs(baseTokenChange).toFixed(4)} ${baseToken.symbol}, ${Math.abs(quoteTokenChange).toFixed(4)} ${quoteToken.symbol}`, + `Added ${baseTokenChange.toFixed(4)} ${baseToken.symbol}, ${quoteTokenChange.toFixed(4)} ${quoteToken.symbol}`, ); return { @@ -166,70 +175,19 @@ export async function openPosition( data: { fee: totalFee / 1e9, positionAddress: positionNftMint.publicKey.toString(), - positionRent: 0, // Simplified - not extracting rent from transaction - baseTokenAmountAdded: Math.abs(baseTokenChange), - quoteTokenAmountAdded: Math.abs(quoteTokenChange), + positionRent: rentLocked, + baseTokenAmountAdded: baseTokenChange, + quoteTokenAmountAdded: quoteTokenChange, }, }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING }; } - -export const openPositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: OpenPositionResponseType; - }>( - '/open-position', - { - schema: { - description: 'Open a new PancakeSwap Solana CLMM position with Token2022 NFT', - tags: ['/connector/pancakeswap-sol'], - body: PancakeswapSolClmmOpenPositionRequest, - response: { - 200: OpenPositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - walletAddress, - poolAddress, - lowerPrice, - upperPrice, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.body; - - return await openPosition( - network, - walletAddress!, - poolAddress, - lowerPrice, - upperPrice, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e: any) { - logger.error('Open position error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to open position'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default openPositionRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts b/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts index 581fbf5a45..b55644bbea 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts @@ -1,12 +1,16 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { PoolInfo } from '../../../schemas/clmm-schema'; import { PancakeswapSol } from '../pancakeswap-sol'; -import { PancakeswapSolClmmGetPoolInfoRequest } from '../schemas'; +import { computeBinDistribution } from '../pancakeswap-sol.bins'; -export async function getPoolInfo(fastify: FastifyInstance, network: string, poolAddress: string): Promise { +export async function getPoolInfo( + fastify: FastifyInstance, + network: string, + poolAddress: string, + binCount: number = 0, +): Promise { const pancakeswap = await PancakeswapSol.getInstance(network); if (!poolAddress) { @@ -19,41 +23,23 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); } + // Optionally include the per-bin distribution around the current tick — only + // fires the extra tick-array fetch when binCount > 0 so default latency is + // unchanged (same contract as orca/raydium). + if (binCount > 0) { + const solana = await Solana.getInstance(network); + poolInfo.bins = await computeBinDistribution({ + connection: solana.connection, + poolAddress, + tickSpacing: poolInfo.binStep as number, + currentTick: poolInfo.activeBinId as number, + currentPrice: poolInfo.price, + liquidity: (poolInfo as any)._liquidity as bigint, + decimals0: (poolInfo as any)._mintDecimals0 as number, + decimals1: (poolInfo as any)._mintDecimals1 as number, + binCount, + }); + } + return poolInfo; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPoolInfoRequestType; - Reply: PoolInfo; - }>( - '/pool-info', - { - schema: { - description: 'Get CLMM pool information from PancakeSwap Solana', - tags: ['/connector/pancakeswap-sol'], - querystring: PancakeswapSolClmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { network = 'mainnet-beta', poolAddress } = request.query; - return await getPoolInfo(fastify, network, poolAddress); - } catch (e: any) { - logger.error('Pool info error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to fetch pool info'; - throw fastify.httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/positionInfo.ts b/src/connectors/pancakeswap-sol/clmm-routes/positionInfo.ts index 4cef11e9a1..5a10289998 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/positionInfo.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/positionInfo.ts @@ -1,10 +1,7 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { Solana } from '../../../chains/solana/solana'; -import { GetPositionInfoRequestType, PositionInfo, PositionInfoSchema } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { PancakeswapSol } from '../pancakeswap-sol'; -import { PancakeswapSolClmmGetPositionInfoRequest } from '../schemas'; export async function getPositionInfo( fastify: FastifyInstance, @@ -24,39 +21,3 @@ export async function getPositionInfo( return positionInfo; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get CLMM position information from PancakeSwap Solana', - tags: ['/connector/pancakeswap-sol'], - querystring: PancakeswapSolClmmGetPositionInfoRequest, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { network = 'mainnet-beta', positionAddress } = request.query; - return await getPositionInfo(fastify, network, positionAddress); - } catch (e: any) { - logger.error('Position info error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to fetch position info'; - throw fastify.httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/positionsOwned.ts b/src/connectors/pancakeswap-sol/clmm-routes/positionsOwned.ts index e14f0fceea..6dd651ea45 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/positionsOwned.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/positionsOwned.ts @@ -1,18 +1,10 @@ -import { Type, Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { PositionInfo, PositionInfoSchema } from '../../../schemas/clmm-schema'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { PancakeswapSol } from '../pancakeswap-sol'; -import { PancakeswapSolClmmGetPositionsOwnedRequest, PancakeswapSolClmmGetPositionsOwnedRequestType } from '../schemas'; - -const INVALID_SOLANA_ADDRESS_MESSAGE = (address: string) => `Invalid Solana address: ${address}`; - -const GetPositionsOwnedResponse = Type.Array(PositionInfoSchema); - -type GetPositionsOwnedResponseType = Static; export async function getPositionsOwned( fastify: FastifyInstance, @@ -111,39 +103,3 @@ async function fetchPositionsFromRPC(solana: Solana, walletAddress: string): Pro logger.info(`Found ${positions.length} PancakeSwap position(s) for wallet ${walletAddress.slice(0, 8)}...`); return positions; } - -export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: PancakeswapSolClmmGetPositionsOwnedRequestType; - Reply: GetPositionsOwnedResponseType; - }>( - '/positions-owned', - { - schema: { - description: "Retrieve all positions owned by a user's wallet across all PancakeSwap Solana CLMM pools", - tags: ['/connector/pancakeswap-sol'], - querystring: PancakeswapSolClmmGetPositionsOwnedRequest, - response: { - 200: GetPositionsOwnedResponse, - }, - }, - }, - async (request) => { - try { - const { network = 'mainnet-beta', walletAddress, poolAddress } = request.query; - return await getPositionsOwned(fastify, network, walletAddress, poolAddress); - } catch (e: any) { - logger.error('Positions owned error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to fetch positions'; - throw fastify.httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default positionsOwnedRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/quotePosition.ts b/src/connectors/pancakeswap-sol/clmm-routes/quotePosition.ts index a0018de29c..6a31c6a285 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/quotePosition.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/quotePosition.ts @@ -1,19 +1,11 @@ -import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - import { Solana } from '../../../chains/solana/solana'; -import { QuotePositionResponse, QuotePositionResponseType } from '../../../schemas/clmm-schema'; +import { QuotePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapSol } from '../pancakeswap-sol'; import { PancakeswapSolConfig } from '../pancakeswap-sol.config'; -import { - getLiquidityFromAmounts, - getLiquidityFromSingleAmount, - getAmountsFromLiquidity, -} from '../pancakeswap-sol.math'; +import { getLiquidityFromSingleAmount, getAmountsFromLiquidity } from '../pancakeswap-sol.math'; import { priceToTick, roundTickToSpacing, tickToPrice } from '../pancakeswap-sol.parser'; -import { PancakeswapSolClmmQuotePositionRequest } from '../schemas'; /** * Quote position with proper CLMM math @@ -286,56 +278,3 @@ async function quotePosition( } export { quotePosition }; - -export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: Static; - Reply: QuotePositionResponseType; - }>( - '/quote-position', - { - schema: { - description: 'Quote position amounts for PancakeSwap Solana CLMM (simplified)', - tags: ['/connector/pancakeswap-sol'], - querystring: PancakeswapSolClmmQuotePositionRequest, - response: { - 200: QuotePositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.query; - - return await quotePosition( - network, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e: any) { - logger.error('Quote position error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to quote position'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default quotePositionRoute; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts b/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts index 4b9d5013b3..693d495717 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/quoteSwap.ts @@ -1,12 +1,9 @@ -import { FastifyPluginAsync } from 'fastify'; - import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapResponseType, QuoteSwapResponse } from '../../../schemas/clmm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapSol } from '../pancakeswap-sol'; import { PancakeswapSolConfig } from '../pancakeswap-sol.config'; -import { PancakeswapSolClmmQuoteSwapRequest, PancakeswapSolClmmQuoteSwapRequestType } from '../schemas'; /** * Quote swap implementation using pool data with fee and price impact estimation. @@ -144,58 +141,6 @@ export async function getRawSwapQuote( return result; } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: PancakeswapSolClmmQuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: - 'Get swap quote for PancakeSwap Solana CLMM with fee and estimated price impact based on pool liquidity', - tags: ['/connector/pancakeswap-sol'], - querystring: PancakeswapSolClmmQuoteSwapRequest, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - baseToken, - quoteToken, - amount, - side, - poolAddress, - slippagePct, - } = request.query; - - return await getRawSwapQuote( - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - poolAddress, - slippagePct, - ); - } catch (e: any) { - logger.error('Quote swap error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to get swap quote'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Resolves the counter ("quote") token for a PancakeSwap Solana CLMM pool given the base token. The * standardized swap wrappers take poolAddress + baseToken and derive the other side from the pool, diff --git a/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts b/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts index baaba8fa4e..89e0132c81 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts @@ -1,17 +1,15 @@ -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import Decimal from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; +import { accountLifecycleSol, liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapSol, PANCAKESWAP_CLMM_PROGRAM_ID } from '../pancakeswap-sol'; import { parsePositionData } from '../pancakeswap-sol.parser'; import { buildRemoveLiquidityTransaction } from '../pancakeswap-sol.transactions'; -import { PancakeswapSolClmmRemoveLiquidityRequest } from '../schemas'; export async function removeLiquidity( network: string, @@ -76,6 +74,7 @@ export async function removeLiquidity( liquidityToRemove, new BN(0), // amount0Min new BN(0), // amount1Min + [baseToken.address, quoteToken.address], // unwrap a native side rather than leaving it WSOL 600000, // Compute units priorityFeePerCU, ); @@ -95,64 +94,39 @@ export async function removeLiquidity( quoteToken.address, ]); - const baseTokenChange = balanceChanges[0]; - const quoteTokenChange = balanceChanges[1]; + // Unwrapping closes the wrapped-SOL account, so its rent — and any WSOL the wallet + // was already holding in it — lands in the native balance change alongside the + // withdrawal. None of that is liquidity this position gave back. + const { closed } = accountLifecycleSol(txData); + const baseTokenChange = liquidityWithoutRent(balanceChanges[0], new PublicKey(baseToken.address), closed); + const quoteTokenChange = liquidityWithoutRent(balanceChanges[1], new PublicKey(quoteToken.address), closed); logger.info(`Liquidity removed successfully. Signature: ${signature}`); logger.info( - `Removed ${Math.abs(baseTokenChange).toFixed(4)} ${baseToken.symbol}, ${Math.abs(quoteTokenChange).toFixed(4)} ${quoteToken.symbol}`, + `Removed ${baseTokenChange.toFixed(4)} ${baseToken.symbol}, ${quoteTokenChange.toFixed(4)} ${quoteToken.symbol}`, ); return { signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: positionInfo.poolAddress, fee: totalFee / 1e9, - baseTokenAmountRemoved: Math.abs(baseTokenChange), - quoteTokenAmountRemoved: Math.abs(quoteTokenChange), + baseTokenAmountRemoved: baseTokenChange, + quoteTokenAmountRemoved: quoteTokenChange, }, }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING }; } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a PancakeSwap Solana CLMM position', - tags: ['/connector/pancakeswap-sol'], - body: PancakeswapSolClmmRemoveLiquidityRequest, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network = 'mainnet-beta', walletAddress, positionAddress, percentageToRemove } = request.body; - - return await removeLiquidity(network, walletAddress!, positionAddress, percentageToRemove); - } catch (e: any) { - logger.error('Remove liquidity error:', e); - // Re-throw httpErrors as-is - if (e.statusCode) { - throw e; - } - // Handle unknown errors - const errorMessage = e.message || 'Failed to remove liquidity'; - throw httpErrors.internalServerError(errorMessage); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.bins.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.bins.ts new file mode 100644 index 0000000000..d2b5a881ff --- /dev/null +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.bins.ts @@ -0,0 +1,123 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +import { BinLiquidity } from '../../schemas/clmm-schema'; +import { logger } from '../../services/logger'; + +import { getAmountsFromLiquidity } from './pancakeswap-sol.math'; +import { getTickArrayAddress, getTickArrayStartIndexFromTick, tickToPrice } from './pancakeswap-sol.parser'; + +// TickArrayState layout (idl/clmm.json — identical to Raydium CLMM, which this +// program forks): discriminator(8) + pool_id(32) + start_tick_index(i32, 4) + +// ticks[60 x TickState] + initialized_tick_count(u8) + recent_epoch(u64) + padding. +// TickState: tick(i32, 4) + liquidity_net(i128, 16) + liquidity_gross(u128, 16) + +// fee_growth_outside_0_x64(16) + fee_growth_outside_1_x64(16) + +// reward_growths_outside_x64(3 x 16) + padding(13 x u32, 52) = 168 bytes. +const TICK_ARRAY_HEADER = 8 + 32 + 4; +const TICK_STATE_SIZE = 168; + +function readI128LE(data: Buffer, offset: number): bigint { + let value = 0n; + for (let i = 0; i < 16; i++) { + value += BigInt(data[offset + i]) << BigInt(8 * i); + } + if (value >= 1n << 127n) { + value -= 1n << 128n; + } + return value; +} + +function liquidityNetAt(arrays: Map, tickIndex: number, tickSpacing: number): bigint { + const startIndex = getTickArrayStartIndexFromTick(tickIndex, tickSpacing); + const data = arrays.get(startIndex); + if (!data) return 0n; + const slot = Math.floor((tickIndex - startIndex) / tickSpacing); + const base = TICK_ARRAY_HEADER + slot * TICK_STATE_SIZE; + if (base + TICK_STATE_SIZE > data.length) return 0n; + // A zeroed (uninitialized) slot fails the tick check; its liquidity_net would + // be 0 anyway, so either way the boundary contributes nothing. + if (data.readInt32LE(base) !== tickIndex) return 0n; + return readI128LE(data, base + 4); +} + +/** + * Per-bin liquidity distribution around the current tick, mirroring the + * raydium/orca bin walk: propagate active liquidity outward across initialized + * tick boundaries, then convert each bin's L into token amounts. + */ +export async function computeBinDistribution(args: { + connection: Connection; + poolAddress: string; + tickSpacing: number; + currentTick: number; + currentPrice: number; // human units (quote per base) + liquidity: bigint; // active liquidity at the current tick + decimals0: number; + decimals1: number; + binCount: number; +}): Promise { + const { connection, poolAddress, tickSpacing, currentTick, currentPrice, liquidity, decimals0, decimals1, binCount } = + args; + if (binCount <= 0) return []; + + // Snap the bin grid so the current tick lands inside a bin. + const halfBins = Math.floor(binCount / 2); + const snapped = Math.floor(currentTick / tickSpacing) * tickSpacing; + const firstBinStart = snapped - halfBins * tickSpacing; + const boundaries: number[] = []; + for (let i = 0; i <= binCount; i++) { + boundaries.push(firstBinStart + i * tickSpacing); + } + + // Fetch every tick array the boundaries touch in one RPC round-trip. + const poolPubkey = new PublicKey(poolAddress); + const startIndexes = [...new Set(boundaries.map((t) => getTickArrayStartIndexFromTick(t, tickSpacing)))]; + const arrays = new Map(); + try { + const accounts = await connection.getMultipleAccountsInfo( + startIndexes.map((s) => getTickArrayAddress(poolPubkey, s)), + ); + accounts.forEach((account, i) => { + if (account) arrays.set(startIndexes[i], account.data); + }); + } catch (e) { + logger.warn(`pancakeswap-sol bin fetch failed for ${poolAddress}: ${e}`); + return []; + } + + // Propagate L outward from the current bin across boundary liquidity_net. + const curIdx = Math.floor((currentTick - firstBinStart) / tickSpacing); + const binLs: bigint[] = new Array(binCount); + binLs[curIdx] = liquidity; + for (let i = curIdx + 1; i < binCount; i++) { + binLs[i] = binLs[i - 1] + liquidityNetAt(arrays, boundaries[i], tickSpacing); + } + for (let i = curIdx - 1; i >= 0; i--) { + binLs[i] = binLs[i + 1] - liquidityNetAt(arrays, boundaries[i + 1], tickSpacing); + } + + const decimalDiff = decimals0 - decimals1; + const bins: BinLiquidity[] = []; + for (let i = 0; i < binCount; i++) { + const tickStart = boundaries[i]; + const priceLower = tickToPrice(tickStart, decimalDiff); + const priceUpper = tickToPrice(boundaries[i + 1], decimalDiff); + let baseTokenAmount = 0; + let quoteTokenAmount = 0; + const L = binLs[i]; + if (L > 0n) { + const amounts = getAmountsFromLiquidity( + currentPrice, + priceLower, + priceUpper, + new BN(L.toString()), + decimals0, + decimals1, + ); + baseTokenAmount = amounts.amount0; + quoteTokenAmount = amounts.amount1; + } + bins.push({ binId: tickStart, price: priceLower, baseTokenAmount, quoteTokenAmount }); + } + return bins; +} diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.fees.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.fees.ts new file mode 100644 index 0000000000..4507ada9dd --- /dev/null +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.fees.ts @@ -0,0 +1,156 @@ +import { BorshCoder } from '@coral-xyz/anchor'; +import { AccountInfo, PublicKey } from '@solana/web3.js'; + +import { getTickArrayAddress, getTickArrayStartIndexFromTick } from './pancakeswap-sol.parser'; + +const clmmIdl = require('./idl/clmm.json'); + +/** + * Uncollected fees for a CLMM position, from the same numbers the program uses. + * + * `position_info` used to answer 0 for both sides unconditionally, with a TODO saying a + * wrong number would be worse than no number. But zero *is* a number and it was being + * stored: nothing downstream could tell "this connector does not compute fees" from + * "this position earned nothing". A position that sat in range while the pool traded + * through it — composition moving from 0.009789 SOL / 0.910 USDC to 0.008894 / 0.988 in + * five minutes, which only happens if swaps crossed it — reported 0 throughout, while + * the collect-fees route harvested a real amount from the same position minutes later. + * + * The arithmetic is Uniswap v3's, which this program (a Raydium CLMM fork) implements: + * a tick records the fee growth accumulated on the *far* side of it, so the growth + * inside a range is the global growth less the part below the lower tick and the part + * above the upper tick, with which side of each tick is "outside" depending on where the + * price is now. Multiply the growth accrued since the position last checkpointed by its + * liquidity, and add whatever was already owed at that checkpoint. + * + * Everything is unsigned 128-bit and the program subtracts with wrapping arithmetic + * deliberately: fee growth accumulators are allowed to overflow, and only the difference + * between two readings is meaningful. Subtracting without wrapping produces an enormous + * positive number the moment an accumulator laps, which is exactly the "plausible but + * wrong" answer the old TODO was afraid of. + */ + +const U128 = 1n << 128n; +const Q64 = 1n << 64n; + +/** u128 subtraction that wraps, as the on-chain math does. */ +export function wrappingSub(a: bigint, b: bigint): bigint { + return (((a - b) % U128) + U128) % U128; +} + +export interface TickFeeGrowth { + /** The tick's index. */ + tick: number; + /** fee_growth_outside_0_x64 as stored. */ + outside0: bigint; + /** fee_growth_outside_1_x64 as stored. */ + outside1: bigint; +} + +/** + * Fee growth accumulated inside a tick range, per unit of liquidity, in X64. + * + * `tickCurrent` decides what "outside" means for each boundary: below the lower tick the + * stored value is the growth *below* it, at or above it the growth is everything else. + */ +export function feeGrowthInside( + tickCurrent: number, + lower: TickFeeGrowth, + upper: TickFeeGrowth, + feeGrowthGlobal0: bigint, + feeGrowthGlobal1: bigint, +): { inside0: bigint; inside1: bigint } { + const below0 = tickCurrent >= lower.tick ? lower.outside0 : wrappingSub(feeGrowthGlobal0, lower.outside0); + const below1 = tickCurrent >= lower.tick ? lower.outside1 : wrappingSub(feeGrowthGlobal1, lower.outside1); + const above0 = tickCurrent < upper.tick ? upper.outside0 : wrappingSub(feeGrowthGlobal0, upper.outside0); + const above1 = tickCurrent < upper.tick ? upper.outside1 : wrappingSub(feeGrowthGlobal1, upper.outside1); + + return { + inside0: wrappingSub(wrappingSub(feeGrowthGlobal0, below0), above0), + inside1: wrappingSub(wrappingSub(feeGrowthGlobal1, below1), above1), + }; +} + +/** + * What a collect would pay out right now, in the token's smallest units. + * + * `owed` is what the position had banked at its last checkpoint; the rest is what has + * accrued since. A position with no liquidity accrues nothing and is owed whatever it + * banked, which is the case a just-emptied position is in. + */ +export function pendingFee( + owed: bigint, + liquidity: bigint, + feeGrowthInsideNow: bigint, + feeGrowthInsideLast: bigint, +): bigint { + const accrued = (wrappingSub(feeGrowthInsideNow, feeGrowthInsideLast) * liquidity) / Q64; + return owed + accrued; +} + +/** + * Read the two tick states a position's range is bounded by, and compute what a collect + * would pay out right now. + * + * The tick arrays are decoded with the program's own IDL rather than by counting bytes: + * a `TickState` is 168 bytes of mixed i128/u128/arrays sixty times over inside a + * `TickArrayState`, and a single wrong offset here would produce a confident, plausible, + * wrong fee figure — the exact failure the old hardcoded zero was chosen to avoid. + * + * Throws rather than guessing when a tick array is missing. A position's own tick arrays + * hold its liquidity, so their absence means something is wrong that a fabricated zero + * would hide. + */ +export async function readPendingFees( + connection: { getMultipleAccountsInfo: (keys: PublicKey[]) => Promise<(AccountInfo | null)[]> }, + poolId: PublicKey, + tickSpacing: number, + tickCurrent: number, + feeGrowthGlobal0: bigint, + feeGrowthGlobal1: bigint, + position: { + tickLowerIndex: number; + tickUpperIndex: number; + liquidity: bigint; + feeGrowthInside0Last: bigint; + feeGrowthInside1Last: bigint; + tokenFeesOwed0: bigint; + tokenFeesOwed1: bigint; + }, +): Promise<{ fee0Raw: bigint; fee1Raw: bigint }> { + const lowerStart = getTickArrayStartIndexFromTick(position.tickLowerIndex, tickSpacing); + const upperStart = getTickArrayStartIndexFromTick(position.tickUpperIndex, tickSpacing); + const [lowerArray, upperArray] = await connection.getMultipleAccountsInfo([ + getTickArrayAddress(poolId, lowerStart), + getTickArrayAddress(poolId, upperStart), + ]); + + const readTick = (account: AccountInfo | null, tick: number, start: number): TickFeeGrowth => { + if (!account) { + throw new Error( + `Tick array at ${start} for pool ${poolId.toBase58()} not found: a position's own tick arrays ` + + 'hold its liquidity, so uncollected fees cannot be computed for it.', + ); + } + const decoded: any = new BorshCoder(clmmIdl).accounts.decode('TickArrayState', account.data); + const state = decoded.ticks[(tick - start) / tickSpacing]; + return { + tick, + outside0: BigInt(state.fee_growth_outside_0_x64.toString()), + outside1: BigInt(state.fee_growth_outside_1_x64.toString()), + }; + }; + + const { inside0, inside1 } = feeGrowthInside( + tickCurrent, + readTick(lowerArray, position.tickLowerIndex, lowerStart), + readTick(upperArray, position.tickUpperIndex, upperStart), + feeGrowthGlobal0, + feeGrowthGlobal1, + ); + + return { + fee0Raw: pendingFee(position.tokenFeesOwed0, position.liquidity, inside0, position.feeGrowthInside0Last), + fee1Raw: pendingFee(position.tokenFeesOwed1, position.liquidity, inside1, position.feeGrowthInside1Last), + }; +} diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts index 37a61adb5b..0f3d5a0d22 100644 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.instructions.ts @@ -3,20 +3,9 @@ import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, - NATIVE_MINT, getAssociatedTokenAddressSync, - createAssociatedTokenAccountInstruction, } from '@solana/spl-token'; -import { - PublicKey, - TransactionInstruction, - SystemProgram, - SYSVAR_RENT_PUBKEY, - ComputeBudgetProgram, - TransactionMessage, - VersionedTransaction, - Keypair, -} from '@solana/web3.js'; +import { PublicKey, TransactionInstruction, SystemProgram, SYSVAR_RENT_PUBKEY, Keypair } from '@solana/web3.js'; import BN from 'bn.js'; import { Solana } from '../../chains/solana/solana'; @@ -600,7 +589,19 @@ export async function buildOpenPositionWithToken22NftInstruction( amount0Max: BN, amount1Max: BN, withMetadata: boolean, - baseFlag: boolean, + /** + * Which side the *program* should size liquidity from, or null to use the `liquidity` + * argument and treat the maxes as the ceilings they are named for. + * + * null is what the open route sends. Handing the program a side makes that side's + * max both the amount to size from and the ceiling to check against, so the check has + * no headroom by construction: it computes the deposit that liquidity requires, + * rounds it up in the pool's favour, and asserts it against the number it started + * from. One unit of rounding fails the whole transaction, and a wider slippagePct + * cannot help — a larger bound is simply a larger deposit. + */ + baseFlag: boolean | null, + liquidity: BN = new BN(0), ): Promise { // Get pool data const poolAccountInfo = await solana.connection.getAccountInfo(poolAddress); @@ -694,11 +695,15 @@ export async function buildOpenPositionWithToken22NftInstruction( tick_upper_index: tickUpperIndex, tick_array_lower_start_index: tickArrayLowerStartIndex, tick_array_upper_start_index: tickArrayUpperStartIndex, - liquidity: new BN(0), // Let program calculate from amounts + liquidity, amount_0_max: amount0Max, amount_1_max: amount1Max, with_metadata: withMetadata, - base_flag: baseFlag ? { some: true } : { some: false }, + // The plain value, not `{ some: ... }`. Borsh encodes an Option as "0x00" for null + // or "0x01" + the value, and its bool layout is `value ? 1 : 0` — so an object is + // truthy and `{ some: false }` encoded as Some(TRUE). Every request that meant + // "size from the quote side" told the program to size from the base side instead. + base_flag: baseFlag, }); logger.info(`Instruction Data (hex): ${instructionData.toString('hex')}`); diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts index 3150e800fb..80fac96bac 100644 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts @@ -1,5 +1,4 @@ import BN from 'bn.js'; -import Decimal from 'decimal.js'; /** * CLMM (Concentrated Liquidity Market Maker) Math Utilities @@ -106,13 +105,14 @@ export function getAmountsFromLiquidity( let amount1Raw: number; if (currentPrice < lowerPrice) { - // Price below range - all liquidity in token1 - amount0Raw = 0; - amount1Raw = liquidityNum * (sqrtPriceUpper - sqrtPriceLower); - } else if (currentPrice >= upperPrice) { - // Price above range - all liquidity in token0 + // Price below range - all liquidity in token0: the range sits above the + // market, so the pool sells token0 as price rises through it. amount0Raw = (liquidityNum * (sqrtPriceUpper - sqrtPriceLower)) / (sqrtPriceLower * sqrtPriceUpper); amount1Raw = 0; + } else if (currentPrice >= upperPrice) { + // Price above range - all liquidity in token1 (token0 fully sold on the way up) + amount0Raw = 0; + amount1Raw = liquidityNum * (sqrtPriceUpper - sqrtPriceLower); } else { // Price in range amount0Raw = (liquidityNum * (sqrtPriceUpper - sqrtPriceCurrent)) / (sqrtPriceCurrent * sqrtPriceUpper); diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.routes.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.routes.ts deleted file mode 100644 index cabb2ecd87..0000000000 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { pancakeswapSolClmmRoutes } from './clmm-routes'; - -export const pancakeswapSolRoutes: FastifyPluginAsync = async (fastify) => { - // Register CLMM routes under /clmm prefix - await fastify.register(pancakeswapSolClmmRoutes, { prefix: '/clmm' }); -}; - -export default pancakeswapSolRoutes; diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.transactions.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.transactions.ts index 850ad38002..bc7a90ad2e 100644 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.transactions.ts +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.transactions.ts @@ -1,16 +1,7 @@ -import { BorshCoder, Idl } from '@coral-xyz/anchor'; -import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID, - NATIVE_MINT, - getAssociatedTokenAddressSync, - createAssociatedTokenAccountInstruction, -} from '@solana/spl-token'; +import { NATIVE_MINT, getAssociatedTokenAddressSync, createAssociatedTokenAccountInstruction } from '@solana/spl-token'; import { PublicKey, TransactionInstruction, - SYSVAR_RENT_PUBKEY, ComputeBudgetProgram, TransactionMessage, VersionedTransaction, @@ -28,16 +19,7 @@ import { buildIncreaseLiquidityV2Instruction, buildOpenPositionWithToken22NftInstruction, } from './pancakeswap-sol.instructions'; -import { - getTokenProgramForMint, - getTickArrayStartIndexFromTick, - getTickArrayAddress, - parsePositionData, - parsePoolTickSpacing, - MEMO_PROGRAM_ID, -} from './pancakeswap-sol.parser'; - -const clmmIdl = require('./idl/clmm.json') as Idl; +import { getTokenProgramForMint, parsePositionData } from './pancakeswap-sol.parser'; export async function buildSwapTransaction( solana: Solana, @@ -188,6 +170,32 @@ export async function buildClosePositionTransaction( return new VersionedTransaction(messageV0); } +/** + * The instruction that turns a withdrawal's wrapped SOL back into SOL. + * + * Every route here that takes tokens *out* of a pool — decreasing liquidity, closing a + * position, collecting fees — receives the native side as WSOL in the wallet's associated + * token account, because that is what the program transfers to. Nothing used to unwrap it, + * so a caller closing a SOL position saw their SOL balance move by the rent alone while + * the withdrawal sat wrapped in an account no response field mentioned. Every close left + * another balance parked there, and the account's own rent with it. + * + * Closing the account is the unwrap: the lamports, both the wrapped balance and the + * account's rent, go back to the owner. The swap path in this same file has always done + * this for a native output; the liquidity paths simply never did. + * + * Returns nothing when neither side of the pool is native, which is the common case. + * WSOL is always a legacy SPL mint, so the token program is never in question here. + */ +export function buildUnwrapSolInstructions( + solana: Solana, + walletPubkey: PublicKey, + mints: string[], +): TransactionInstruction[] { + const hasNativeSide = mints.some((mint) => mint === NATIVE_MINT.toBase58()); + return hasNativeSide ? [solana.unwrapSOL(walletPubkey)] : []; +} + export async function buildTransactionWithInstructions( solana: Solana, walletPubkey: PublicKey, @@ -231,6 +239,7 @@ export async function buildRemoveLiquidityTransaction( liquidityToRemove: BN, amount0Min: BN, amount1Min: BN, + poolMints: string[], computeUnits: number = 600000, priorityFeePerCU?: number, ): Promise { @@ -259,6 +268,9 @@ export async function buildRemoveLiquidityTransaction( // Add remove liquidity instruction instructions.push(removeLiqIx); + // What the program just paid out in WSOL, back to SOL. + instructions.push(...buildUnwrapSolInstructions(solana, walletPubkey, poolMints)); + // Get recent blockhash const { blockhash } = await solana.connection.getLatestBlockhash('confirmed'); @@ -389,7 +401,8 @@ export async function buildOpenPositionTransaction( amount0Max: BN, amount1Max: BN, withMetadata: boolean, - baseFlag: boolean, + baseFlag: boolean | null, + liquidity: BN, computeUnits: number = 800000, priorityFeePerCU?: number, ): Promise<{ transaction: VersionedTransaction; positionNftMint: Keypair }> { @@ -470,10 +483,18 @@ export async function buildOpenPositionTransaction( amount1Max, withMetadata, baseFlag, + liquidity, ); instructions.push(openPositionIx); + // The deposit now lands below the wrapped maximum rather than exactly on it, so the + // difference stays wrapped unless this closes the account. Same instruction the swap + // path uses for a native output; it also returns the account's own rent. + instructions.push( + ...buildUnwrapSolInstructions(solana, walletPubkey, [token0Mint.toBase58(), token1Mint.toBase58()]), + ); + const { blockhash } = await solana.connection.getLatestBlockhash('confirmed'); const messageV0 = new TransactionMessage({ diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.ts index acf01502dc..1bc87d6c57 100644 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.ts +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.ts @@ -10,6 +10,7 @@ import { logger } from '../../services/logger'; import clmmIdl from './idl/clmm.json'; import { PancakeswapSolConfig } from './pancakeswap-sol.config'; +import { readPendingFees } from './pancakeswap-sol.fees'; import { getAmountsFromLiquidity } from './pancakeswap-sol.math'; import { tickToPrice } from './pancakeswap-sol.parser'; @@ -145,8 +146,13 @@ export class PancakeswapSol { const tickSpacing = data.readUInt16LE(offset); offset += 2; - // Read liquidity (16 bytes, u128) - not currently used but part of data structure - // const liquidity = data.readBigUInt64LE(offset); + // Read liquidity (16 bytes, u128) - the active liquidity at the current tick, + // needed for the bin distribution walk + const liquidityBytes = data.slice(offset, offset + 16); + let liquidityValue = BigInt(0); + for (let i = 0; i < 16; i++) { + liquidityValue += BigInt(liquidityBytes[i]) << BigInt(i * 8); + } offset += 16; // Read sqrt_price_x64 (16 bytes, u128) @@ -260,6 +266,11 @@ export class PancakeswapSol { (poolInfo as any)._feeGrowthGlobal0 = feeGrowthGlobal0; (poolInfo as any)._feeGrowthGlobal1 = feeGrowthGlobal1; (poolInfo as any)._rewardGrowthGlobalX64 = rewardGrowthGlobalX64; + // Raw pool state for the bin distribution walk (poolInfo.binStep/activeBinId + // carry tickSpacing/tickCurrent already; these carry the rest) + (poolInfo as any)._liquidity = liquidityValue; + (poolInfo as any)._mintDecimals0 = mintDecimals0; + (poolInfo as any)._mintDecimals1 = mintDecimals1; return poolInfo; } catch (error) { @@ -535,11 +546,35 @@ export class PancakeswapSol { const baseTokenAmount = amounts.amount0; const quoteTokenAmount = amounts.amount1; - // TODO: Fix fee and reward calculations for PancakeSwap-Sol - // Setting to 0 for now to avoid showing incorrect information - const baseFeeAmount = 0; - const quoteFeeAmount = 0; - const cakeRewardAmount = 0; + // What a collect would pay out right now. This returned a hardcoded 0 for both + // sides, with a TODO reasoning that a wrong number is worse than none — but zero IS + // a number and it was being stored, so nothing downstream could tell "this connector + // does not compute fees" from "this position earned nothing". A position that sat in + // range while the pool traded through it reported 0 throughout, while the + // collect-fees route harvested a real amount from it minutes later. + // + // Everything the calculation needs was already read here and written to logger.debug + // before being discarded: the position's checkpoint, the pool's global growth, and + // the two boundary ticks (fetched below, decoded with the program's own IDL). + const { fee0Raw, fee1Raw } = await readPendingFees( + this.solana.connection, + manualPoolId, + poolInfo.binStep, // tick spacing + poolInfo.activeBinId, // current tick + poolFeeGrowthGlobal0, + poolFeeGrowthGlobal1, + { + tickLowerIndex, + tickUpperIndex, + liquidity: BigInt(liquidity.toString()), + feeGrowthInside0Last, + feeGrowthInside1Last, + tokenFeesOwed0, + tokenFeesOwed1, + }, + ); + const baseFeeAmount = Number(fee0Raw) / 10 ** baseTokenInfo.decimals; + const quoteFeeAmount = Number(fee1Raw) / 10 ** quoteTokenInfo.decimals; return { address: positionAddress, diff --git a/src/connectors/pancakeswap-sol/schemas.ts b/src/connectors/pancakeswap-sol/schemas.ts deleted file mode 100644 index bcf2b1924a..0000000000 --- a/src/connectors/pancakeswap-sol/schemas.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; - -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -import { PancakeswapSolConfig } from './pancakeswap-sol.config'; - -// Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); - -// Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.01; -const BASE_TOKEN_AMOUNT = 0.01; -const QUOTE_TOKEN_AMOUNT = 2; -const LOWER_PRICE_BOUND = 150; -const UPPER_PRICE_BOUND = 250; -const CLMM_POOL_ADDRESS_EXAMPLE = '4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN'; -const POSITION_ADDRESS_EXAMPLE = ''; - -// CLMM Pool Info Request -export const PancakeswapSolClmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'PancakeSwap CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), -}); - -export type PancakeswapSolClmmGetPoolInfoRequestType = Static; - -// CLMM Open Position Request -export const PancakeswapSolClmmOpenPositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'PancakeSwap CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapSolConfig.config.slippagePct, - examples: [PancakeswapSolConfig.config.slippagePct], - }), - ), -}); - -export type PancakeswapSolClmmOpenPositionRequestType = Static; - -// CLMM Create Pool Request -export const PancakeswapSolClmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will create and initialize the pool', - default: solanaChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. If omitted, the current market price is fetched from the ' + - 'unified swap router so the pool opens on-market. No position is opened; only the pool is created.', - examples: [QUOTE_TOKEN_AMOUNT / BASE_TOKEN_AMOUNT], - }), - ), - ammConfig: Type.String({ - description: - 'Base58 address of an existing on-chain amm_config account for the desired fee tier. ' + - 'There is no API to enumerate amm_config accounts, so this must be supplied explicitly. ' + - 'The pool_state PDA is derived from this config plus the (canonically ordered) token mints.', - examples: ['E64NGkDLLCdQ2yFNPcavaKptrEgmiQaNykUuLC1Qgwyp'], - }), -}); - -export type PancakeswapSolClmmCreatePoolRequestType = Static; - -// CLMM Position Info Request -export const PancakeswapSolClmmGetPositionInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [POSITION_ADDRESS_EXAMPLE], - }), -}); - -export type PancakeswapSolClmmGetPositionInfoRequestType = Static; - -// CLMM Get Positions Owned Request -export const PancakeswapSolClmmGetPositionsOwnedRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.String({ - description: 'Solana wallet address to check for positions', - examples: [solanaChainConfig.defaultWallet], - }), - poolAddress: Type.Optional( - Type.String({ - description: 'Optional pool address to filter positions by specific pool', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), -}); - -export type PancakeswapSolClmmGetPositionsOwnedRequestType = Static; - -// CLMM Quote Swap Request -export const PancakeswapSolClmmQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'CLMM pool address (optional - can be looked up from tokens)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapSolConfig.config.slippagePct, - examples: [PancakeswapSolConfig.config.slippagePct], - }), - ), -}); - -export type PancakeswapSolClmmQuoteSwapRequestType = Static; - -// CLMM Execute Swap Request -export const PancakeswapSolClmmExecuteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'CLMM pool address (optional)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapSolConfig.config.slippagePct, - examples: [PancakeswapSolConfig.config.slippagePct], - }), - ), -}); - -export type PancakeswapSolClmmExecuteSwapRequestType = Static; - -// CLMM Close Position Request -export const PancakeswapSolClmmClosePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address to close', - examples: [POSITION_ADDRESS_EXAMPLE], - }), -}); - -export type PancakeswapSolClmmClosePositionRequestType = Static; - -// CLMM Remove Liquidity Request -export const PancakeswapSolClmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address to remove liquidity from', - examples: [POSITION_ADDRESS_EXAMPLE], - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - examples: [100], - }), -}); - -export type PancakeswapSolClmmRemoveLiquidityRequestType = Static; - -// CLMM Collect Fees Request -export const PancakeswapSolClmmCollectFeesRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [POSITION_ADDRESS_EXAMPLE], - }), -}); - -export type PancakeswapSolClmmCollectFeesRequestType = Static; - -// CLMM Add Liquidity Request -export const PancakeswapSolClmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [POSITION_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - examples: [QUOTE_TOKEN_AMOUNT], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapSolConfig.config.slippagePct, - examples: [PancakeswapSolConfig.config.slippagePct], - }), - ), -}); - -export type PancakeswapSolClmmAddLiquidityRequestType = Static; - -// CLMM Quote Position Request -export const PancakeswapSolClmmQuotePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...PancakeswapSolConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'PancakeSwap CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapSolConfig.config.slippagePct, - examples: [PancakeswapSolConfig.config.slippagePct], - }), - ), -}); - -export type PancakeswapSolClmmQuotePositionRequestType = Static; diff --git a/src/connectors/pancakeswap/amm-routes/addLiquidity.ts b/src/connectors/pancakeswap/amm-routes/addLiquidity.ts index b992bfef1a..fc616a158b 100644 --- a/src/connectors/pancakeswap/amm-routes/addLiquidity.ts +++ b/src/connectors/pancakeswap/amm-routes/addLiquidity.ts @@ -1,19 +1,17 @@ import { Contract } from '@ethersproject/contracts'; import { Percent } from '@pancakeswap/sdk'; -import { Static } from '@sinclair/typebox'; -import { BigNumber, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; +import { BigNumber } from 'ethers'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { wrapEthereum } from '../../../chains/ethereum/routes/wrap'; -import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/amm-schema'; +import { AddLiquidityResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; import { IPancakeswapV2Router02ABI } from '../pancakeswap.contracts'; import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; -import { PancakeswapAmmAddLiquidityRequest } from '../schemas'; import { getPancakeswapAmmLiquidityQuote } from './quoteLiquidity'; @@ -30,8 +28,6 @@ async function addLiquidityInternal( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = PancakeswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const networkToUse = network; @@ -180,8 +176,7 @@ async function addLiquidityInternal( // Add liquidity Token + ETH // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); gasOptions.value = quote.rawQuoteTokenAmount; tx = await router.addLiquidityETH( @@ -242,8 +237,7 @@ async function addLiquidityInternal( // Add liquidity Token + Token // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); tx = await router.addLiquidity( quote.baseTokenObj.address, @@ -259,19 +253,17 @@ async function addLiquidityInternal( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the quoted amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountAdded: quote.baseTokenAmount, quoteTokenAmountAdded: quote.quoteTokenAmount, ...(baseWrapTxHash && { baseWrapTxHash }), @@ -291,8 +283,6 @@ export async function addLiquidity( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = PancakeswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const poolInfo = await getPancakeswapPoolInfo(poolAddress, network, 'amm'); if (!poolInfo) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); @@ -306,92 +296,5 @@ export async function addLiquidity( baseTokenAmount, quoteTokenAmount, slippagePct, - gasPrice, - maxGas, ); } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to a Pancakeswap V2 pool', - tags: ['/connector/pancakeswap'], - body: PancakeswapAmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - walletAddress: requestedWalletAddress, - gasPrice, - maxGas, - } = request.body; - - // Validate essential parameters - if (!poolAddress || !baseTokenAmount || !quoteTokenAmount) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - const networkToUse = network; - - // Get wallet address - either from request or first available - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - return await addLiquidity( - networkToUse, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - gasPrice, - maxGas, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - - // Handle specific user-actionable errors - if (e.message && e.message.includes('Insufficient allowance')) { - logger.error('Request error:', e); - throw fastify.httpErrors.badRequest('Invalid request'); - } - - // Handle insufficient funds errors - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', - ); - } - - throw fastify.httpErrors.internalServerError('Failed to add liquidity'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/pancakeswap/amm-routes/createPool.ts b/src/connectors/pancakeswap/amm-routes/createPool.ts index 148caefae9..87f59721d3 100644 --- a/src/connectors/pancakeswap/amm-routes/createPool.ts +++ b/src/connectors/pancakeswap/amm-routes/createPool.ts @@ -1,12 +1,11 @@ import { Contract } from '@ethersproject/contracts'; -import { Static } from '@sinclair/typebox'; import { Percent } from '@uniswap/sdk-core'; import { Decimal } from 'decimal.js'; import { BigNumber, constants, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapConfig } from '../pancakeswap.config'; @@ -18,7 +17,6 @@ import { getPancakeswapV2RouterAddress, } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; -import { PancakeswapAmmCreatePoolRequest } from '../schemas'; // Default gas limit for AMM create-pool operations (pair creation + initial mint costs more than a plain add). // Pancakeswap V2 pools all share a fixed 0.25% swap fee — there is no fee parameter to set. @@ -54,10 +52,10 @@ async function fetchMarketPrice( quoteToken: string, amount: number, ): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + quote = await getSwapQuote(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + @@ -80,8 +78,6 @@ export async function createPool( baseTokenAmount: number, quoteTokenAmount?: number, initialPrice?: number, - gasPrice?: number, - maxGas?: number, slippagePct: number = PancakeswapConfig.config.slippagePct, ): Promise { if (baseTokenAmount <= 0) { @@ -178,10 +174,6 @@ export async function createPool( const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - // gasPrice arrives already in gwei (the unit prepareGasOptions expects). The connector's Fastify - // route accepts gasPrice as a wei string (sibling shape) and converts it to gwei before calling. - const gasPriceGwei = gasPrice; - let tx; if (baseIsEth || quoteIsEth) { // One side is ETH/WETH → addLiquidityETH. The ERC20 side needs an allowance to the router; the @@ -204,7 +196,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); gasOptions.value = ethRawAmount; tx = await router.addLiquidityETH( @@ -248,7 +240,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); tx = await router.addLiquidity( baseTokenInfo.address, @@ -265,118 +257,32 @@ export async function createPool( logger.info(`Creating Pancakeswap V2 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pair address from the factory — authoritative source of the pool address. const pairAddress: string = await factory.getPair(baseTokenInfo.address, quoteTokenInfo.address); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can + // reconcile it. The seed amounts below have not moved, so they are deliberately omitted. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress: pairAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: baseTokenAmount, - quoteTokenAmountAdded: effectiveQuoteAmount, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress: pairAddress, price: seedPrice, + data: { + fee: outcome.fee, + baseTokenAmountAdded: baseTokenAmount, + quoteTokenAmountAdded: effectiveQuoteAmount, + }, }; } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: 'Create a new Pancakeswap V2 (AMM) pool and seed it with initial liquidity (fixed 0.25% fee)', - tags: ['/connector/pancakeswap'], - body: PancakeswapAmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - initialPrice, - slippagePct, - gasPrice, - maxGas, - walletAddress: requestedWalletAddress, - } = request.body; - - if (!baseToken || !quoteToken || !baseTokenAmount) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - // Route accepts gasPrice as a wei string (matching sibling AMM requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - - return await createPool( - network, - walletAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - initialPrice, - gasPriceGwei, - maxGas, - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - - if (e.message && e.message.includes('Insufficient allowance')) { - throw fastify.httpErrors.badRequest(e.message); - } - if (e.message && e.message.includes('already exists')) { - throw fastify.httpErrors.badRequest(e.message); - } - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient native balance to pay for gas fees. Please add more funds to your wallet.', - ); - } - - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/pancakeswap/amm-routes/executeSwap.ts b/src/connectors/pancakeswap/amm-routes/executeSwap.ts index a0a2584c0a..f6e443e5d4 100644 --- a/src/connectors/pancakeswap/amm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/amm-routes/executeSwap.ts @@ -1,17 +1,15 @@ import { BigNumber, Contract, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; -import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; import { getPancakeswapV2RouterAddress, IPancakeswapV2Router02ABI } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; -import { PancakeswapAmmExecuteSwapRequest } from '../schemas'; import { resolveSwapPair } from './poolTokens'; import { getPancakeswapAmmQuote } from './quoteSwap'; @@ -94,7 +92,7 @@ export async function executeAmmSwap( // Prepare transaction parameters const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -159,7 +157,7 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -216,19 +214,18 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction confirmed: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction confirmed: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -238,27 +235,22 @@ export async function executeAmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { @@ -286,51 +278,6 @@ export async function executeAmmSwap( } } -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on Pancakeswap V2 AMM using Router02', - tags: ['/connector/pancakeswap'], - body: PancakeswapAmmExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const ethereumConfig = getEthereumChainConfig(); - const { - walletAddress = ethereumConfig.defaultWallet, - network = ethereumConfig.defaultNetwork, - baseToken, - quoteToken, - amount, - side = 'SELL', - slippagePct, - } = request.body as typeof PancakeswapAmmExecuteSwapRequest._type; - - return await executeAmmSwap( - walletAddress, - network, - baseToken, - quoteToken || '', // Handle optional quoteToken - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - /** * Standard AMM execute-swap entry point (network-based) — consumed by the unified /trading/amm * dispatcher. The quote token is derived from the pool; `amount` is denominated in the base token. @@ -347,5 +294,3 @@ export async function executeSwap( const { baseAddress, quoteAddress } = await resolveSwapPair(network, poolAddress, baseToken); return await executeAmmSwap(walletAddress, network, baseAddress, quoteAddress, amount, side, slippagePct); } - -export default executeSwapRoute; diff --git a/src/connectors/pancakeswap/amm-routes/index.ts b/src/connectors/pancakeswap/amm-routes/index.ts deleted file mode 100644 index 2819290e8d..0000000000 --- a/src/connectors/pancakeswap/amm-routes/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import addLiquidityRoute from './addLiquidity'; -import createPoolRoute from './createPool'; -import executeSwapRoute from './executeSwap'; -import poolInfoRoute from './poolInfo'; -import positionInfoRoute from './positionInfo'; -import quoteLiquidityRoute from './quoteLiquidity'; -import quoteSwapRoute from './quoteSwap'; -import removeLiquidityRoute from './removeLiquidity'; - -export const pancakeswapAmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionInfoRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(quoteLiquidityRoute); - await fastify.register(executeSwapRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(createPoolRoute); -}; - -export default pancakeswapAmmRoutes; diff --git a/src/connectors/pancakeswap/amm-routes/poolInfo.ts b/src/connectors/pancakeswap/amm-routes/poolInfo.ts index 29474d6d18..773a79aa8e 100644 --- a/src/connectors/pancakeswap/amm-routes/poolInfo.ts +++ b/src/connectors/pancakeswap/amm-routes/poolInfo.ts @@ -1,14 +1,11 @@ import { Contract } from '@ethersproject/contracts'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { PoolInfo } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { IPancakeswapV2PairABI } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; -import { PancakeswapAmmGetPoolInfoRequest } from '../schemas'; /** * Standard AMM pool-info accessor: given a network and a Pancakeswap V2 pool (pair) address, returns @@ -57,48 +54,3 @@ export async function getPoolInfo(network: string, poolAddress: string): Promise quoteTokenAmount, }; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPoolInfoRequestType; - Reply: Record; - }>( - '/pool-info', - { - schema: { - description: 'Get AMM pool information from Pancakeswap V2', - tags: ['/connector/pancakeswap'], - querystring: PancakeswapAmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress, network } = request.query; - return await getPoolInfo(network, poolAddress); - } catch (e) { - logger.error(`Error in pool-info route: ${e.message}`); - if (e.stack) { - logger.debug(`Stack trace: ${e.stack}`); - } - - // Return appropriate error based on the error message - if (e.statusCode) { - throw e; // Already a formatted Fastify error - } else if (e.message && e.message.includes('invalid address')) { - throw fastify.httpErrors.badRequest(`Invalid pool address`); - } else if (e.message && e.message.includes('not found')) { - logger.error('Not found error:', e); - throw fastify.httpErrors.notFound('Resource not found'); - } else { - logger.error('Unexpected error fetching pool info:', e); - throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); - } - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/pancakeswap/amm-routes/positionInfo.ts b/src/connectors/pancakeswap/amm-routes/positionInfo.ts index d63e176926..bf268f92dd 100644 --- a/src/connectors/pancakeswap/amm-routes/positionInfo.ts +++ b/src/connectors/pancakeswap/amm-routes/positionInfo.ts @@ -1,16 +1,9 @@ import { Contract } from '@ethersproject/contracts'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - GetPositionInfoRequestType, - GetPositionInfoRequest, - PositionInfo, - PositionInfoSchema, -} from '../../../schemas/amm-schema'; +import { PositionInfo } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { IPancakeswapV2PairABI } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; @@ -97,142 +90,3 @@ export async function checkLPAllowance( ); } } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get position information for a Pancakeswap V2 pool', - tags: ['/connector/pancakeswap'], - querystring: { - ...GetPositionInfoRequest, - properties: { - network: { type: 'string', default: 'base' }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - poolAddress: { - type: 'string', - examples: [''], - }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - }, - }, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { network, poolAddress, walletAddress: requestedWalletAddress } = request.query; - - const networkToUse = network; - - // Validate essential parameters - if (!poolAddress) { - throw fastify.httpErrors.badRequest('Pool address is required'); - } - - // Get Pancakeswap and Ethereum instances - const pancakeswap = await Pancakeswap.getInstance(networkToUse); - const ethereum = await Ethereum.getInstance(networkToUse); - - // Get wallet address - either from request or first available - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await pancakeswap.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - // Get the pair contract - const pairContract = new Contract(poolAddress, IPancakeswapV2PairABI.abi, ethereum.provider); - - // Get LP token balance for the wallet - const lpBalance = await pairContract.balanceOf(walletAddress); - - // Get token addresses from the pair - const [token0, token1] = await Promise.all([pairContract.token0(), pairContract.token1()]); - - // Get token objects by address - const baseTokenObj = await pancakeswap.getToken(token0); - const quoteTokenObj = await pancakeswap.getToken(token1); - - if (!baseTokenObj || !quoteTokenObj) { - throw fastify.httpErrors.badRequest('Token information not found for pool'); - } - - // If no position, return early - if (lpBalance.isZero()) { - return { - poolAddress, - walletAddress, - baseTokenAddress: baseTokenObj.address, - quoteTokenAddress: quoteTokenObj.address, - lpTokenAmount: 0, - baseTokenAmount: 0, - quoteTokenAmount: 0, - price: 0, - }; - } - - // Get total supply and reserves - const [totalSupply, reserves] = await Promise.all([pairContract.totalSupply(), pairContract.getReserves()]); - - // Determine which token is base and which is quote - const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); - - // Calculate token amounts - const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; - const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; - - const userBaseTokenAmount = baseTokenReserve.mul(lpBalance).div(totalSupply); - const userQuoteTokenAmount = quoteTokenReserve.mul(lpBalance).div(totalSupply); - - // Calculate price (quoteToken per baseToken) - const baseTokenAmountFloat = formatTokenAmount(baseTokenReserve.toString(), baseTokenObj.decimals); - const quoteTokenAmountFloat = formatTokenAmount(quoteTokenReserve.toString(), quoteTokenObj.decimals); - const price = quoteTokenAmountFloat / baseTokenAmountFloat; - - // Format for response - logger.info(`Raw LP balance: ${lpBalance.toString()}`); - logger.info(`Total supply: ${totalSupply.toString()}`); - - const formattedLpAmount = formatTokenAmount(lpBalance.toString(), 18); // LP tokens have 18 decimals - const formattedBaseAmount = formatTokenAmount(userBaseTokenAmount.toString(), baseTokenObj.decimals); - const formattedQuoteAmount = formatTokenAmount(userQuoteTokenAmount.toString(), quoteTokenObj.decimals); - - logger.info(`Formatted LP amount: ${formattedLpAmount}`); - logger.info(`Formatted base amount: ${formattedBaseAmount}`); - logger.info(`Formatted quote amount: ${formattedQuoteAmount}`); - - return { - poolAddress, - walletAddress, - baseTokenAddress: baseTokenObj.address, - quoteTokenAddress: quoteTokenObj.address, - lpTokenAmount: formattedLpAmount, - baseTokenAmount: formattedBaseAmount, - quoteTokenAmount: formattedQuoteAmount, - price, - }; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to get position info'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts b/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts index b58eea303a..0e8a2f80e6 100644 --- a/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts +++ b/src/connectors/pancakeswap/amm-routes/quoteLiquidity.ts @@ -1,18 +1,12 @@ import { Contract } from '@ethersproject/contracts'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - QuoteLiquidityRequestType, - QuoteLiquidityRequest, - QuoteLiquidityResponseType, - QuoteLiquidityResponse, -} from '../../../schemas/amm-schema'; +import { QuoteLiquidityResponseType } from '../../../schemas/amm-schema'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { IPancakeswapV2PairABI, getPancakeswapV2RouterAddress } from '../pancakeswap.contracts'; -import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; +import { formatTokenAmount } from '../pancakeswap.utils'; import { getAmmPoolTokens } from './poolTokens'; @@ -82,7 +76,7 @@ export async function getPancakeswapAmmLiquidityQuote( const pairContract = new Contract(poolAddressToUse, IPancakeswapV2PairABI.abi, ethereum.provider); // Get token addresses and reserves - const [token0, token1, reserves] = await Promise.all([ + const [token0, , reserves] = await Promise.all([ pairContract.token0(), pairContract.token1(), pairContract.getReserves(), @@ -175,87 +169,6 @@ export async function getPancakeswapAmmLiquidityQuote( }; } -export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - fastify.get<{ - Querystring: QuoteLiquidityRequestType; - Reply: QuoteLiquidityResponseType; - }>( - '/quote-liquidity', - { - schema: { - description: 'Get liquidity quote for a Pancakeswap V2 pool', - tags: ['/connector/pancakeswap'], - querystring: { - ...QuoteLiquidityRequest, - properties: { - ...QuoteLiquidityRequest.properties, - network: { type: 'string', default: 'base' }, - poolAddress: { - type: 'string', - examples: [''], - }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - baseTokenAmount: { type: 'number', examples: [0.001] }, - quoteTokenAmount: { type: 'number', examples: [2.5] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { - 200: QuoteLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; - - if (!poolAddress) { - throw fastify.httpErrors.badRequest('Pool address is required'); - } - - // Get pool information to determine tokens - const poolInfo = await getPancakeswapPoolInfo(poolAddress, network, 'amm'); - if (!poolInfo) { - throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); - } - - const baseToken = poolInfo.baseTokenAddress; - const quoteToken = poolInfo.quoteTokenAddress; - - const quote = await getPancakeswapAmmLiquidityQuote( - network, - poolAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - - // Use standard gas limit for liquidity operations - const computeUnits = 500000; - - return { - baseLimited: quote.baseLimited, - baseTokenAmount: quote.baseTokenAmount, - quoteTokenAmount: quote.quoteTokenAmount, - baseTokenAmountMax: quote.baseTokenAmountMax, - quoteTokenAmountMax: quote.quoteTokenAmountMax, - computeUnits, - }; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to get liquidity quote'); - } - }, - ); -}; - /** * Standard AMM quote-liquidity entry point (network-based) — consumed by the unified /trading/amm * dispatcher. Base/quote follow the pair's token0/token1 orientation. @@ -285,5 +198,3 @@ export async function quoteLiquidity( quoteTokenAmountMax: q.quoteTokenAmountMax, }; } - -export default quoteLiquidityRoute; diff --git a/src/connectors/pancakeswap/amm-routes/quoteSwap.ts b/src/connectors/pancakeswap/amm-routes/quoteSwap.ts index 0533675f01..9b6bf02a94 100644 --- a/src/connectors/pancakeswap/amm-routes/quoteSwap.ts +++ b/src/connectors/pancakeswap/amm-routes/quoteSwap.ts @@ -1,20 +1,14 @@ import { Token, CurrencyAmount, Percent, TradeType } from '@pancakeswap/sdk'; import { Route as V2Route, Trade as V2Trade } from '@pancakeswap/v2-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - QuoteSwapRequestType, - QuoteSwapResponseType, - QuoteSwapRequest, - QuoteSwapResponse, -} from '../../../schemas/amm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; -import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; +import { formatTokenAmount } from '../pancakeswap.utils'; import { resolveSwapPair } from './poolTokens'; @@ -266,144 +260,6 @@ async function formatSwapQuote( } } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - // Import the httpErrors plugin to ensure it's available - await fastify.register(require('@fastify/sensible')); - - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Pancakeswap V2 AMM', - tags: ['/connector/pancakeswap'], - querystring: { - ...QuoteSwapRequest, - properties: { - ...QuoteSwapRequest.properties, - network: { type: 'string', default: 'base' }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - amount: { type: 'number', examples: [0.001] }, - side: { type: 'string', enum: ['BUY', 'SELL'], examples: ['SELL'] }, - poolAddress: { type: 'string', examples: [''] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, poolAddress, baseToken, quoteToken, amount, side, slippagePct } = request.query; - - const networkToUse = network; - - // Validate essential parameters - if (!baseToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, amount, and side are required'); - } - - const pancakeswap = await Pancakeswap.getInstance(networkToUse); - - let poolAddressToUse = poolAddress; - let baseTokenToUse: string; - let quoteTokenToUse: string; - - if (poolAddressToUse) { - // Pool address provided, get pool info to determine tokens - const poolInfo = await getPancakeswapPoolInfo(poolAddressToUse, networkToUse, 'amm'); - if (!poolInfo) { - throw httpErrors.notFound(`Pool not found: ${poolAddressToUse}`); - } - - // Determine which token is base and which is quote based on the provided baseToken - if (baseToken === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (baseToken === poolInfo.quoteTokenAddress) { - // User specified the quote token as base, so swap them - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - // Try to resolve baseToken as symbol to address - const resolvedToken = await pancakeswap.getToken(baseToken); - - if (resolvedToken) { - if (resolvedToken.address === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (resolvedToken.address === poolInfo.quoteTokenAddress) { - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } - } else { - // No pool address provided, need quoteToken to find pool - if (!quoteToken) { - throw httpErrors.badRequest('quoteToken is required when poolAddress is not provided'); - } - - baseTokenToUse = baseToken; - quoteTokenToUse = quoteToken; - - // Find pool using findDefaultPool - poolAddressToUse = await pancakeswap.findDefaultPool(baseTokenToUse, quoteTokenToUse, 'amm'); - - if (!poolAddressToUse) { - throw httpErrors.notFound(`No AMM pool found for pair ${baseTokenToUse}-${quoteTokenToUse}`); - } - } - - return await formatSwapQuote( - networkToUse, - poolAddressToUse, - baseTokenToUse, - quoteTokenToUse, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - logger.error(`Error in quote-swap route: ${e.message}`); - - // If it's already a Fastify HTTP error, re-throw it - if (e.statusCode) { - throw e; - } - - // Check for specific error types - if (e.message?.includes('Insufficient liquidity')) { - logger.error('Request error:', e); - throw httpErrors.badRequest('Invalid request'); - } - if (e.message?.includes('Pool not found') || e.message?.includes('No AMM pool found')) { - logger.error('Pool not found error:', e); - throw httpErrors.notFound(e.message || 'Pool not found'); - } - if (e.message?.includes('token not found')) { - logger.error('Request error:', e); - throw httpErrors.badRequest('Invalid request'); - } - - // Default to internal server error - logger.error('Unexpected error getting swap quote:', e); - logger.error('Error stack:', e.stack); - throw httpErrors.internalServerError(e.message || 'Error getting swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Standard AMM quote-swap entry point (network-based) — consumed by the unified /trading/amm * dispatcher. `amount` is denominated in the base token; the quote token is derived from the pool. diff --git a/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts b/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts index f50da1bfd2..6830af646e 100644 --- a/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts +++ b/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts @@ -1,13 +1,10 @@ import { Contract } from '@ethersproject/contracts'; import { Percent } from '@pancakeswap/sdk'; -import { Static } from '@sinclair/typebox'; -import { utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; import { @@ -16,7 +13,6 @@ import { IPancakeswapV2PairABI, } from '../pancakeswap.contracts'; import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; -import { PancakeswapAmmRemoveLiquidityRequest } from '../schemas'; import { checkLPAllowance } from './positionInfo'; @@ -33,8 +29,6 @@ export async function removeLiquidity( poolAddress: string, percentageToRemove: number, slippagePct: number = PancakeswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { if (!poolAddress || !percentageToRemove) throw httpErrors.badRequest('Missing required parameters'); if (percentageToRemove <= 0 || percentageToRemove > 100) { @@ -89,8 +83,7 @@ export async function removeLiquidity( await checkLPAllowance(ethereum, wallet, poolAddress, routerAddress, liquidityToRemove); const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_REMOVE_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_REMOVE_LIQUIDITY_GAS_LIMIT); let tx; if (baseTokenObj.symbol === 'WETH') { @@ -126,81 +119,22 @@ export async function removeLiquidity( ); } - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the expected amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } + const baseTokenAmountRemoved = formatTokenAmount(expectedBaseTokenAmount.toString(), baseTokenObj.decimals); const quoteTokenAmountRemoved = formatTokenAmount(expectedQuoteTokenAmount.toString(), quoteTokenObj.decimals); - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, }; } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a Pancakeswap V2 pool', - tags: ['/connector/pancakeswap'], - body: PancakeswapAmmRemoveLiquidityRequest, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - poolAddress, - percentageToRemove, - walletAddress: requestedWalletAddress, - gasPrice, - maxGas, - } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - return await removeLiquidity( - network, - walletAddress, - poolAddress, - percentageToRemove, - undefined, - gasPrice, - maxGas, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', - ); - } - throw fastify.httpErrors.internalServerError('Failed to remove liquidity'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts b/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts index 430235e0c2..0e0c76e8e5 100644 --- a/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts +++ b/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts @@ -1,19 +1,20 @@ import { Contract } from '@ethersproject/contracts'; import { CurrencyAmount, Percent } from '@pancakeswap/sdk'; -import { Position, NonfungiblePositionManager } from '@pancakeswap/v3-sdk'; -import { Static } from '@sinclair/typebox'; +import { Position, NonfungiblePositionManager, computePoolAddress } from '@pancakeswap/v3-sdk'; import { BigNumber, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { AddLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; -import { getPancakeswapV3NftManagerAddress, POSITION_MANAGER_ABI } from '../pancakeswap.contracts'; +import { + getPancakeswapV3NftManagerAddress, + POSITION_MANAGER_ABI, + getPancakeswapV3PoolDeployerAddress, +} from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; -import { PancakeswapClmmAddLiquidityRequest } from '../schemas'; // Default gas limit for CLMM add liquidity operations const CLMM_ADD_LIQUIDITY_GAS_LIMIT = 600000; @@ -43,6 +44,16 @@ export async function addLiquidity( const token0 = await pancakeswap.getToken(position.token0); const token1 = await pancakeswap.getToken(position.token1); + + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + deployerAddress: getPancakeswapV3PoolDeployerAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); const fee = position.fee; const tickLower = position.tickLower; const tickUpper = position.tickUpper; @@ -157,9 +168,12 @@ export async function addLiquidity( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_ADD_LIQUIDITY_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the mint amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const actualToken0Amount = formatTokenAmount(newPosition.mintAmounts.amount0.toString(), token0.decimals); const actualToken1Amount = formatTokenAmount(newPosition.mintAmounts.amount1.toString(), token1.decimals); @@ -167,69 +181,13 @@ export async function addLiquidity( const actualQuoteAmount = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, baseTokenAmountAdded: actualBaseAmount, quoteTokenAmountAdded: actualQuoteAmount, }, }; } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to an existing Pancakeswap V3 position', - tags: ['/connector/pancakeswap'], - body: PancakeswapClmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress: requestedWalletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const pancakeswap = await Pancakeswap.getInstance(network); - walletAddress = await pancakeswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await addLiquidity( - network, - walletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e: any) { - logger.error('Failed to add liquidity:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to add liquidity'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/closePosition.ts b/src/connectors/pancakeswap/clmm-routes/closePosition.ts index 1fcbdd567b..080b222ce0 100644 --- a/src/connectors/pancakeswap/clmm-routes/closePosition.ts +++ b/src/connectors/pancakeswap/clmm-routes/closePosition.ts @@ -1,21 +1,21 @@ import { Contract } from '@ethersproject/contracts'; import { Percent, CurrencyAmount } from '@pancakeswap/sdk'; -import { NonfungiblePositionManager, Position } from '@pancakeswap/v3-sdk'; +import { NonfungiblePositionManager, Position, computePoolAddress } from '@pancakeswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - ClosePositionRequestType, - ClosePositionRequest, - ClosePositionResponseType, - ClosePositionResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { ClosePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { slippageBasisPoints } from '../../evm-slippage'; import { Pancakeswap } from '../pancakeswap'; -import { POSITION_MANAGER_ABI, getPancakeswapV3NftManagerAddress } from '../pancakeswap.contracts'; +import { PancakeswapConfig } from '../pancakeswap.config'; +import { + POSITION_MANAGER_ABI, + getPancakeswapV3NftManagerAddress, + getPancakeswapV3PoolDeployerAddress, +} from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; // Default gas limit for CLMM close position operations @@ -25,6 +25,7 @@ export async function closePosition( network: string, walletAddress: string, positionAddress: string, + slippagePct: number = PancakeswapConfig.config.slippagePct, ): Promise { if (!positionAddress) { throw httpErrors.badRequest('Missing required parameters'); @@ -54,6 +55,16 @@ export async function closePosition( const token0 = await pancakeswap.getToken(position.token0); const token1 = await pancakeswap.getToken(position.token1); + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + deployerAddress: getPancakeswapV3PoolDeployerAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); + const isBaseToken0 = token0.symbol === 'WETH' || (token1.symbol !== 'WETH' && token0.address.toLowerCase() < token1.address.toLowerCase()); @@ -82,7 +93,10 @@ export async function closePosition( const amount0 = positionSDK.amount0; const amount1 = positionSDK.amount1; - const slippageTolerance = new Percent(100, 10000); + // The caller's tolerance, or the connector's configured one — not a literal. This was + // `new Percent(100, 10000)`, a flat 1% that ignored both, so an operator who had widened + // slippagePct for a volatile pair got 1% anyway and a revert that cost gas. + const slippageTolerance = new Percent(slippageBasisPoints(slippagePct), 10000); const totalAmount0 = CurrencyAmount.fromRawAmount(token0, BigInt(amount0.quotient) + BigInt(feeAmount0.toString())); const totalAmount1 = CurrencyAmount.fromRawAmount(token1, BigInt(amount1.quotient) + BigInt(feeAmount1.toString())); @@ -119,9 +133,12 @@ export async function closePosition( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_CLOSE_POSITION_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); const token1AmountRemoved = formatTokenAmount(totalAmount1.quotient.toString(), token1.decimals); @@ -137,10 +154,11 @@ export async function closePosition( const positionRentRefunded = 0; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, positionRentRefunded, baseTokenAmountRemoved, quoteTokenAmountRemoved, @@ -149,46 +167,3 @@ export async function closePosition( }, }; } - -export const closePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ClosePositionRequestType; - Reply: ClosePositionResponseType; - }>( - '/close-position', - { - schema: { - description: 'Close a Pancakeswap V3 position by removing all liquidity and collecting fees', - tags: ['/connector/pancakeswap'], - body: ClosePositionRequest, - response: { - 200: ClosePositionResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress: requestedWalletAddress, positionAddress } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const pancakeswap = await Pancakeswap.getInstance(network); - walletAddress = await pancakeswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await closePosition(network, walletAddress, positionAddress); - } catch (e: any) { - logger.error('Failed to close position:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to close position'); - } - }, - ); -}; - -export default closePositionRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/collectFees.ts b/src/connectors/pancakeswap/clmm-routes/collectFees.ts index 7366604a0b..42c2f91efd 100644 --- a/src/connectors/pancakeswap/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap/clmm-routes/collectFees.ts @@ -1,21 +1,19 @@ import { Contract } from '@ethersproject/contracts'; import { CurrencyAmount } from '@pancakeswap/sdk'; -import { NonfungiblePositionManager } from '@pancakeswap/v3-sdk'; +import { NonfungiblePositionManager, computePoolAddress } from '@pancakeswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - CollectFeesRequestType, - CollectFeesRequest, - CollectFeesResponseType, - CollectFeesResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { CollectFeesResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; -import { POSITION_MANAGER_ABI, getPancakeswapV3NftManagerAddress } from '../pancakeswap.contracts'; +import { + POSITION_MANAGER_ABI, + getPancakeswapV3NftManagerAddress, + getPancakeswapV3PoolDeployerAddress, +} from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; // Default gas limit for CLMM collect fees operations @@ -54,6 +52,16 @@ export async function collectFees( const token0 = await pancakeswap.getToken(position.token0); const token1 = await pancakeswap.getToken(position.token1); + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + deployerAddress: getPancakeswapV3PoolDeployerAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); + const isBaseToken0 = token0.symbol === 'WETH' || (token1.symbol !== 'WETH' && token0.address.toLowerCase() < token1.address.toLowerCase()); @@ -94,9 +102,12 @@ export async function collectFees( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the fee amounts below were read before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const token0FeeAmount = formatTokenAmount(feeAmount0.toString(), token0.decimals); const token1FeeAmount = formatTokenAmount(feeAmount1.toString(), token1.decimals); @@ -104,70 +115,13 @@ export async function collectFees( const quoteFeeAmountCollected = isBaseToken0 ? token1FeeAmount : token0FeeAmount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, baseFeeAmountCollected, quoteFeeAmountCollected, }, }; } - -export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: CollectFeesRequestType; - Reply: CollectFeesResponseType; - }>( - '/collect-fees', - { - schema: { - description: 'Collect fees from a Pancakeswap V3 position', - tags: ['/connector/pancakeswap'], - body: { - ...CollectFeesRequest, - properties: { - ...CollectFeesRequest.properties, - network: { type: 'string', default: 'bsc', examples: ['bsc'] }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - positionAddress: { - type: 'string', - description: 'Position NFT token ID', - examples: ['1234'], - }, - }, - }, - response: { - 200: CollectFeesResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress: requestedWalletAddress, positionAddress } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const pancakeswap = await Pancakeswap.getInstance(network); - walletAddress = await pancakeswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await collectFees(network, walletAddress, positionAddress); - } catch (e: any) { - logger.error('Failed to collect fees:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to collect fees'); - } - }, - ); -}; - -export default collectFeesRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/createPool.ts b/src/connectors/pancakeswap/clmm-routes/createPool.ts index 85e8bda09c..b593ad2766 100644 --- a/src/connectors/pancakeswap/clmm-routes/createPool.ts +++ b/src/connectors/pancakeswap/clmm-routes/createPool.ts @@ -1,13 +1,12 @@ import { Contract } from '@ethersproject/contracts'; -import { Static } from '@sinclair/typebox'; import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; import { Decimal } from 'decimal.js'; -import { BigNumber, constants, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; +import { BigNumber, constants } from 'ethers'; import JSBI from 'jsbi'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { @@ -17,8 +16,6 @@ import { getPancakeswapV3FactoryAddress, getPancakeswapV3NftManagerAddress, } from '../pancakeswap.contracts'; -import { formatTokenAmount } from '../pancakeswap.utils'; -import { PancakeswapClmmCreatePoolRequest } from '../schemas'; // Pancakeswap V3 supported fee tiers (hundredths of a bip). 100=0.01%, 500=0.05%, 2500=0.25%, 10000=1.00%. // NOTE: these differ from Uniswap V3 — Pancakeswap uses 2500 (0.25%) where Uniswap uses 3000 (0.30%). @@ -55,10 +52,10 @@ async function fetchMarketPrice( quoteToken: string, amount: number, ): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + quote = await getSwapQuote(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + @@ -78,8 +75,6 @@ export async function createPool( quoteToken: string, initialPrice?: number, fee?: number, - gasPrice?: number, - maxGas?: number, ): Promise { // Validate the fee tier — V3 only accepts a fixed set of tiers, each mapped to a tick spacing. if (fee === undefined) { @@ -174,7 +169,7 @@ export async function createPool( const nftManagerAddress = getPancakeswapV3NftManagerAddress(network); const nftManager = new Contract(nftManagerAddress, INftManagerCreatePoolABI, wallet); - const gasOptions = await ethereum.prepareGasOptions(gasPrice, maxGas || CLMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, CLMM_CREATE_POOL_GAS_LIMIT); const tx = await nftManager.createAndInitializePoolIfNecessary( token0.address, @@ -186,102 +181,29 @@ export async function createPool( logger.info(`Creating Pancakeswap V3 pool via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pool address from the factory — the authoritative source. const poolAddress: string = await factory.getPool(token0.address, token1.address, fee); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can reconcile it. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: 0, // create-pool only initializes price; no liquidity is seeded - quoteTokenAmountAdded: 0, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress, price: seedPrice, + data: { + fee: outcome.fee, + }, }; } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: 'Create and initialize a new Pancakeswap V3 (CLMM) pool at an initial price (no liquidity seeded)', - tags: ['/connector/pancakeswap'], - body: PancakeswapClmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - baseToken, - quoteToken, - fee, - initialPrice, - gasPrice, - maxGas, - walletAddress: requestedWalletAddress, - } = request.body; - - if (!baseToken || !quoteToken) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - // Route accepts gasPrice as a wei string (matching sibling requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - - return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, fee, gasPriceGwei, maxGas); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - - if (e.message && e.message.includes('already exists')) { - throw fastify.httpErrors.badRequest(e.message); - } - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient native balance to pay for gas fees. Please add more funds to your wallet.', - ); - } - - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts index 47fd5e91da..4bb1d08ab4 100644 --- a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts @@ -1,17 +1,16 @@ import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; import { BigNumber, Contract, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; import { getPancakeswapV3SwapRouter02Address, ISwapRouter02ABI } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; -import { PancakeswapExecuteSwapRequest } from '../schemas'; import { getPancakeswapClmmQuote, resolveCounterToken } from './quoteSwap'; @@ -109,7 +108,7 @@ export async function executeClmmSwap( ).toString(), }; - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -186,7 +185,7 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -249,19 +248,18 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction hash: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction hash: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -271,27 +269,22 @@ export async function executeClmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { @@ -325,52 +318,5 @@ export async function executeClmmSwap( } } -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on Pancakeswap V3 CLMM using SwapRouter02', - tags: ['/connector/pancakeswap'], - body: PancakeswapExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct } = - request.body as typeof PancakeswapExecuteSwapRequest._type; - - // This route resolves the pool from the pair (no poolAddress in its request schema); - // executeClmmSwap itself is standardized to require poolAddress. - const pancakeswap = await Pancakeswap.getInstance(network); - const poolAddress = await pancakeswap.findDefaultPool(baseToken, quoteToken, 'clmm'); - if (!poolAddress) { - throw httpErrors.notFound(`No CLMM pool found for pair ${baseToken}-${quoteToken}`); - } - - return await executeClmmSwap( - network, - walletAddress, - poolAddress, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - // Export executeSwap alias for uniform chain route imports export { executeClmmSwap as executeSwap }; - -export default executeSwapRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/index.ts b/src/connectors/pancakeswap/clmm-routes/index.ts deleted file mode 100644 index 8847547e3f..0000000000 --- a/src/connectors/pancakeswap/clmm-routes/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import addLiquidityRoute from './addLiquidity'; -import closePositionRoute from './closePosition'; -import collectFeesRoute from './collectFees'; -import createPoolRoute from './createPool'; -import executeSwapRoute from './executeSwap'; -import openPositionRoute from './openPosition'; -import poolInfoRoute from './poolInfo'; -import positionInfoRoute from './positionInfo'; -import positionsOwnedRoute from './positionsOwned'; -import quotePositionRoute from './quotePosition'; -import quoteSwapRoute from './quoteSwap'; -import removeLiquidityRoute from './removeLiquidity'; - -export const pancakeswapClmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionInfoRoute); - await fastify.register(positionsOwnedRoute); - await fastify.register(quotePositionRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(executeSwapRoute); - await fastify.register(openPositionRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(collectFeesRoute); - await fastify.register(closePositionRoute); - await fastify.register(createPoolRoute); -}; - -export default pancakeswapClmmRoutes; diff --git a/src/connectors/pancakeswap/clmm-routes/openPosition.ts b/src/connectors/pancakeswap/clmm-routes/openPosition.ts index 2574bbe477..5c83b30dc9 100644 --- a/src/connectors/pancakeswap/clmm-routes/openPosition.ts +++ b/src/connectors/pancakeswap/clmm-routes/openPosition.ts @@ -2,16 +2,11 @@ import { Contract } from '@ethersproject/contracts'; import { CurrencyAmount, Percent } from '@pancakeswap/sdk'; import { Position, NonfungiblePositionManager, MintOptions, nearestUsableTick } from '@pancakeswap/v3-sdk'; import { BigNumber, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - OpenPositionRequestType, - OpenPositionRequest, - OpenPositionResponseType, - OpenPositionResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { OpenPositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -239,11 +234,15 @@ export async function openPosition( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — there is no mint log to read yet, so no positionAddress and no amounts. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Find the NFT ID from the transaction logs let positionId = ''; - for (const log of receipt.logs) { + for (const log of outcome.receipt.logs) { if ( log.address.toLowerCase() === positionManagerAddress.toLowerCase() && log.topics[0] === '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' && @@ -254,8 +253,15 @@ export async function openPosition( } } - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + if (!positionId) { + // The transaction confirmed but no NFT-mint Transfer log was found. Returning a CONFIRMED + // response with an empty positionAddress would have the caller record a position it can + // never address — fail loudly, naming the transaction so the position stays recoverable. + throw httpErrors.internalServerError( + `Position opened in transaction ${outcome.signature} but no position NFT mint was found in its logs. ` + + `Inspect the transaction to recover the position ID.`, + ); + } // For position rent, we're using the estimated gas cost since Ethereum doesn't have rent like Solana const positionRent = 0; @@ -269,10 +275,10 @@ export async function openPosition( const quoteAmountUsed = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, positionAddress: positionId, positionRent, baseTokenAmountAdded: baseAmountUsed, @@ -280,93 +286,3 @@ export async function openPosition( }, }; } - -export const openPositionRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: OpenPositionRequestType; - Reply: OpenPositionResponseType; - }>( - '/open-position', - { - schema: { - description: 'Open a new liquidity position in a Pancakeswap V3 pool', - tags: ['/connector/pancakeswap'], - body: { - ...OpenPositionRequest, - properties: { - ...OpenPositionRequest.properties, - network: { type: 'string', default: 'bsc', examples: ['bsc'] }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - lowerPrice: { type: 'number', examples: [0.0008] }, - upperPrice: { type: 'number', examples: [0.001] }, - poolAddress: { - type: 'string', - default: '0x172fcd41e0913e95784454622d1c3724f546f849', - examples: ['0x172fcd41e0913e95784454622d1c3724f546f849'], - }, - baseTokenAmount: { type: 'number', examples: [10] }, - quoteTokenAmount: { type: 'number', examples: [0.01] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { - 200: OpenPositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress: requestedWalletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const pancakeswap = await Pancakeswap.getInstance(network); - walletAddress = await pancakeswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await openPosition( - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e: any) { - logger.error('Failed to open position:', e); - if (e.statusCode) { - throw e; - } - if (e.code === 'CALL_EXCEPTION') { - throw httpErrors.badRequest( - 'Transaction failed. Please check token balances, approvals, and position parameters.', - ); - } - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw httpErrors.badRequest('Insufficient funds to complete the transaction'); - } - throw httpErrors.internalServerError('Failed to open position'); - } - }, - ); -}; - -export default openPositionRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/poolInfo.ts b/src/connectors/pancakeswap/clmm-routes/poolInfo.ts index 8efd314f11..347898ac17 100644 --- a/src/connectors/pancakeswap/clmm-routes/poolInfo.ts +++ b/src/connectors/pancakeswap/clmm-routes/poolInfo.ts @@ -1,13 +1,21 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Contract as EthersProjectContract } from '@ethersproject/contracts'; +import { abi as IPancakeV3PoolABI } from '@pancakeswap/v3-core/artifacts/contracts/interfaces/IPancakeV3Pool.sol/IPancakeV3Pool.json'; +import { SqrtPriceMath, TickMath } from '@pancakeswap/v3-sdk'; +import { FastifyInstance } from 'fastify'; -import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { PoolInfo } from '../../../schemas/clmm-schema'; import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { computeV3BinDistribution } from '../../clmm-v3-utils'; import { Pancakeswap } from '../pancakeswap'; import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; -import { PancakeswapClmmGetPoolInfoRequest } from '../schemas'; -export async function getPoolInfo(fastify: FastifyInstance, network: string, poolAddress: string): Promise { +export async function getPoolInfo( + fastify: FastifyInstance, + network: string, + poolAddress: string, + binCount: number = 0, +): Promise { const pancakeswap = await Pancakeswap.getInstance(network); if (!poolAddress) { @@ -41,9 +49,18 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo const price = isBaseToken0 ? parseFloat(price0) : parseFloat(price1); - const liquidity = pool.liquidity; - const token0Amount = formatTokenAmount(liquidity.toString(), token0.decimals); - const token1Amount = formatTokenAmount(liquidity.toString(), token1.decimals); + // Read the pool contract's actual ERC20 balances. V3's `pool.liquidity` is the + // active virtual liquidity in sqrt-price space, not a token amount — using it + // reported the same figure for both sides, scaled by each token's decimals. + const ethereum = await Ethereum.getInstance(network); + const token0Contract = ethereum.getContract(token0.address, ethereum.provider); + const token1Contract = ethereum.getContract(token1.address, ethereum.provider); + const [token0Balance, token1Balance] = await Promise.all([ + ethereum.getERC20BalanceByAddress(token0Contract, poolAddress, token0.decimals), + ethereum.getERC20BalanceByAddress(token1Contract, poolAddress, token1.decimals), + ]); + const token0Amount = formatTokenAmount(token0Balance.value.toString(), token0.decimals); + const token1Amount = formatTokenAmount(token1Balance.value.toString(), token1.decimals); const baseTokenAmount = isBaseToken0 ? token0Amount : token1Amount; const quoteTokenAmount = isBaseToken0 ? token1Amount : token0Amount; @@ -52,7 +69,7 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo const tickSpacing = pool.tickSpacing; const activeBinId = pool.tickCurrent; - return { + const result: PoolInfo = { address: poolAddress, baseTokenAddress: baseTokenObj.address, quoteTokenAddress: quoteTokenObj.address, @@ -63,38 +80,26 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo quoteTokenAmount: quoteTokenAmount, activeBinId: activeBinId, }; -} -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPoolInfoRequestType; - Reply: Record; - }>( - '/pool-info', - { - schema: { - description: 'Get CLMM pool information from Pancakeswap V3', - tags: ['/connector/pancakeswap'], - querystring: PancakeswapClmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress } = request.query; - const network = request.query.network; - return await getPoolInfo(fastify, network, poolAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); - } - }, - ); -}; - -export default poolInfoRoute; + // Optionally include the per-bin distribution around the current tick. + // Fires N parallel pool.ticks(tick) reads — only when binCount > 0 so the + // default pool-info latency is unaffected. + if (binCount > 0) { + const poolContract = new EthersProjectContract(poolAddress, IPancakeV3PoolABI, ethereum.provider); + result.bins = await computeV3BinDistribution({ + poolContract, + tickSpacing, + currentTick: activeBinId, + currentSqrtPriceX96: BigInt(pool.sqrtRatioX96.toString()), + activeLiquidity: BigInt(pool.liquidity.toString()), + decimals0: token0.decimals, + decimals1: token1.decimals, + isBaseToken0, + binCount, + tickMath: TickMath, + sqrtPriceMath: SqrtPriceMath, + }); + } + + return result; +} diff --git a/src/connectors/pancakeswap/clmm-routes/positionInfo.ts b/src/connectors/pancakeswap/clmm-routes/positionInfo.ts index 754fbde1e6..7baa4f9bdd 100644 --- a/src/connectors/pancakeswap/clmm-routes/positionInfo.ts +++ b/src/connectors/pancakeswap/clmm-routes/positionInfo.ts @@ -1,15 +1,9 @@ import { Contract } from '@ethersproject/contracts'; import { Position, tickToPrice, computePoolAddress } from '@pancakeswap/v3-sdk'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - GetPositionInfoRequestType, - GetPositionInfoRequest, - PositionInfo, - PositionInfoSchema, -} from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { Pancakeswap } from '../pancakeswap'; import { POSITION_MANAGER_ABI, @@ -103,48 +97,3 @@ export async function getPositionInfo( price: parseFloat(price), }; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get position information for a Pancakeswap V3 position', - tags: ['/connector/pancakeswap'], - querystring: { - ...GetPositionInfoRequest, - properties: { - network: { type: 'string', default: 'bsc', examples: ['bsc'] }, - positionAddress: { - type: 'string', - description: 'Position NFT token ID', - examples: ['1234'], - }, - }, - }, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { network, positionAddress } = request.query; - return await getPositionInfo(fastify, network, positionAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to get position info'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts b/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts index 85d4cbacb6..bb0645cc03 100644 --- a/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts +++ b/src/connectors/pancakeswap/clmm-routes/positionsOwned.ts @@ -1,10 +1,9 @@ import { Contract } from '@ethersproject/contracts'; import { Position, tickToPrice, computePoolAddress } from '@pancakeswap/v3-sdk'; -import { Type } from '@sinclair/typebox'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { PositionInfo, PositionInfoSchema } from '../../../schemas/clmm-schema'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; import { @@ -14,14 +13,6 @@ import { } from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; -// Define the request and response types -const PositionsOwnedRequest = Type.Object({ - network: Type.Optional(Type.String({ examples: ['bsc'], default: 'bsc' })), - walletAddress: Type.String({ examples: [''] }), -}); - -const PositionsOwnedResponse = Type.Array(PositionInfoSchema); - // Additional ABI methods needed for enumerating positions const ENUMERABLE_ABI = [ { @@ -142,46 +133,3 @@ export async function getPositionsOwned( return positions; } - -export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.get<{ - Querystring: typeof PositionsOwnedRequest.static; - Reply: typeof PositionsOwnedResponse.static; - }>( - '/positions-owned', - { - schema: { - description: 'Get all Pancakeswap V3 positions owned by a wallet', - tags: ['/connector/pancakeswap'], - querystring: { - ...PositionsOwnedRequest, - properties: { - ...PositionsOwnedRequest.properties, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - }, - }, - response: { - 200: PositionsOwnedResponse, - }, - }, - }, - async (request) => { - try { - const { walletAddress } = request.query; - const network = request.query.network; - return await getPositionsOwned(fastify, network, walletAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to fetch positions'); - } - }, - ); -}; - -export default positionsOwnedRoute; diff --git a/src/connectors/pancakeswap/clmm-routes/quotePosition.ts b/src/connectors/pancakeswap/clmm-routes/quotePosition.ts index 801ec25937..2445d861ab 100644 --- a/src/connectors/pancakeswap/clmm-routes/quotePosition.ts +++ b/src/connectors/pancakeswap/clmm-routes/quotePosition.ts @@ -1,385 +1,13 @@ -import { Position, nearestUsableTick, tickToPrice } from '@pancakeswap/v3-sdk'; -import { utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; +import { Position, nearestUsableTick } from '@pancakeswap/v3-sdk'; import JSBI from 'jsbi'; -import { - QuotePositionRequestType, - QuotePositionRequest, - QuotePositionResponseType, - QuotePositionResponse, -} from '../../../schemas/clmm-schema'; +import { QuotePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Pancakeswap } from '../pancakeswap'; import { getPancakeswapPoolInfo } from '../pancakeswap.utils'; // Constants for examples (BSC USDT-WBNB pool, current price ~0.00093) -const BASE_TOKEN_AMOUNT = 10; -const QUOTE_TOKEN_AMOUNT = 0.01; -const LOWER_PRICE_BOUND = 0.0008; -const UPPER_PRICE_BOUND = 0.001; -const POOL_ADDRESS_EXAMPLE = '0x172fcd41e0913e95784454622d1c3724f546f849'; - -export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuotePositionRequestType; - Reply: QuotePositionResponseType; - }>( - '/quote-position', - { - schema: { - description: 'Get a quote for opening a position on Pancakeswap V3', - tags: ['/connector/pancakeswap'], - querystring: { - ...QuotePositionRequest, - properties: { - ...QuotePositionRequest.properties, - network: { type: 'string', default: 'bsc', examples: ['bsc'] }, - lowerPrice: { type: 'number', examples: [LOWER_PRICE_BOUND] }, - upperPrice: { type: 'number', examples: [UPPER_PRICE_BOUND] }, - poolAddress: { - type: 'string', - default: POOL_ADDRESS_EXAMPLE, - examples: [POOL_ADDRESS_EXAMPLE], - }, - baseTokenAmount: { type: 'number', examples: [BASE_TOKEN_AMOUNT] }, - quoteTokenAmount: { type: 'number', examples: [QUOTE_TOKEN_AMOUNT] }, - }, - }, - response: { - 200: QuotePositionResponse, - }, - }, - }, - async (request) => { - try { - const { network, lowerPrice, upperPrice, poolAddress, baseTokenAmount, quoteTokenAmount } = request.query; - - const networkToUse = network; - - // Validate essential parameters - if ( - !lowerPrice || - !upperPrice || - !poolAddress || - (baseTokenAmount === undefined && quoteTokenAmount === undefined) - ) { - throw httpErrors.badRequest('Missing required parameters'); - } - - // Get Pancakeswap and Ethereum instances - const pancakeswap = await Pancakeswap.getInstance(networkToUse); - - // Get pool information to determine tokens - const poolInfo = await getPancakeswapPoolInfo(poolAddress, networkToUse, 'clmm'); - if (!poolInfo) { - throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddress)); - } - - const baseTokenObj = await pancakeswap.getToken(poolInfo.baseTokenAddress); - const quoteTokenObj = await pancakeswap.getToken(poolInfo.quoteTokenAddress); - - if (!baseTokenObj || !quoteTokenObj) { - throw httpErrors.badRequest('Token information not found for pool'); - } - - // Get the V3 pool - const pool = await pancakeswap.getV3Pool(baseTokenObj, quoteTokenObj, undefined, poolAddress); - if (!pool) { - throw httpErrors.notFound(`Pool not found for ${baseTokenObj.symbol}-${quoteTokenObj.symbol}`); - } - - // Convert price range to ticks - // In Pancakeswap, ticks are log base 1.0001 of price - // We need to convert the user's desired price range to tick range - const token0 = pool.token0; - const token1 = pool.token1; - - // Determine if we need to invert the price depending on which token is token0 - const isBaseToken0 = baseTokenObj.address.toLowerCase() === token0.address.toLowerCase(); - - // Convert prices to ticks - let lowerTick, upperTick; - - // Calculate ticks based on price - // Tick = log(price) / log(1.0001) - - console.log('DEBUG: isBaseToken0:', isBaseToken0); - console.log('DEBUG: baseToken symbol:', baseTokenObj.symbol, 'address:', baseTokenObj.address); - console.log('DEBUG: quoteToken symbol:', quoteTokenObj.symbol, 'address:', quoteTokenObj.address); - console.log('DEBUG: token0:', token0.symbol, 'address:', token0.address); - console.log('DEBUG: token1:', token1.symbol, 'address:', token1.address); - - // CRITICAL INSIGHT: The pool's negative tick is confusing us! - // The pool tick of -197547 actually represents the current price correctly - // but in a way that seems counterintuitive. - // - // The issue is that Pancakeswap stores the price and tick in a specific way: - // - sqrtPriceX96 = sqrt(token1/token0) * 2^96 - // - tick = floor(log(token1/token0) / log(1.0001)) - // - // For this pool: - // - token0 = WETH (18 decimals) - // - token1 = USDC (6 decimals) - // - Human readable price = 2637 USDC per WETH - // - But in raw amounts: 2637 * 10^6 USDC units per 10^18 WETH units - // - So token1/token0 in raw units = (2637 * 10^6) / 10^18 = 2637 * 10^-12 - // - This is a very small number! Hence the negative tick. - - console.log('DEBUG: Current pool tick:', pool.tickCurrent); - console.log('DEBUG: This tick represents token1/token0 in RAW UNITS (not human readable)'); - - // When calculating ticks from human-readable prices, we need to account for decimals - const priceToTickWithDecimals = (humanPrice: number): number => { - // Convert human price (USDC per WETH) to raw price (USDC units per WETH unit) - const rawPrice = humanPrice * Math.pow(10, token1.decimals - token0.decimals); - return Math.floor(Math.log(rawPrice) / Math.log(1.0001)); - }; - - lowerTick = priceToTickWithDecimals(lowerPrice); - upperTick = priceToTickWithDecimals(upperPrice); - - const currentHumanPrice = 2637; // Approximate current price - const expectedCurrentTick = priceToTickWithDecimals(currentHumanPrice); - console.log('DEBUG: Expected current tick for price', currentHumanPrice, ':', expectedCurrentTick); - console.log('DEBUG: Lower price', lowerPrice, '-> tick', lowerTick); - console.log('DEBUG: Upper price', upperPrice, '-> tick', upperTick); - - console.log('DEBUG: Raw calculated lowerTick:', lowerTick); - console.log('DEBUG: Raw calculated upperTick:', upperTick); - - // Ensure ticks are on valid tick spacing boundaries - const tickSpacing = pool.tickSpacing; - lowerTick = nearestUsableTick(lowerTick, tickSpacing); - upperTick = nearestUsableTick(upperTick, tickSpacing); - - console.log('DEBUG: Adjusted lowerTick (after tick spacing):', lowerTick); - console.log('DEBUG: Adjusted upperTick (after tick spacing):', upperTick); - console.log('DEBUG: Pool tick spacing:', tickSpacing); - console.log('DEBUG: Current pool tick:', pool.tickCurrent); - console.log('DEBUG: Pool current price (sqrtPriceX96):', pool.sqrtRatioX96.toString()); - - // Calculate the actual price from sqrtPriceX96 - const sqrtPriceX96 = JSBI.toNumber(JSBI.BigInt(pool.sqrtRatioX96.toString())); - const price = Math.pow(sqrtPriceX96 / Math.pow(2, 96), 2); - console.log('DEBUG: Pool current price (decimal):', price); - console.log( - 'DEBUG: Pool current price (token1/token0):', - price * Math.pow(10, token0.decimals - token1.decimals), - ); - - // Use SDK to convert tick to price for verification - const tickPrice = tickToPrice(token0, token1, pool.tickCurrent); - console.log('DEBUG: Price from current tick:', tickPrice.toSignificant(6)); - console.log('DEBUG: Price from current tick (inverted):', tickPrice.invert().toSignificant(6)); - - // Ensure lower < upper - if (lowerTick >= upperTick) { - throw httpErrors.badRequest('Lower price must be less than upper price'); - } - - // Check if the current price is within the position range - const isInRange = pool.tickCurrent >= lowerTick && pool.tickCurrent <= upperTick; - console.log('DEBUG: Is position in range?', isInRange); - console.log('DEBUG: Position will require both tokens?', isInRange); - - if (!isInRange) { - console.log('WARNING: Position is out of range!'); - console.log( - ' Current tick:', - pool.tickCurrent, - 'is', - pool.tickCurrent < lowerTick ? 'below' : 'above', - 'the range', - ); - console.log( - ' This means the position will only contain', - pool.tickCurrent < lowerTick ? baseTokenObj.symbol : quoteTokenObj.symbol, - ); - } - - // Calculate optimal token amounts - let position: Position; - let baseLimited = false; - - console.log('DEBUG: Input amounts:'); - console.log(' - baseTokenAmount:', baseTokenAmount); - console.log(' - quoteTokenAmount:', quoteTokenAmount); - - if (baseTokenAmount !== undefined && quoteTokenAmount !== undefined) { - console.log('DEBUG: Using fromAmounts (both amounts provided)'); - // Both amounts provided - use fromAmounts to calculate optimal position - // Use parseUnits to avoid scientific notation issues with large numbers - const baseAmountRaw = JSBI.BigInt( - utils.parseUnits(baseTokenAmount.toString(), baseTokenObj.decimals).toString(), - ); - const quoteAmountRaw = JSBI.BigInt( - utils.parseUnits(quoteTokenAmount.toString(), quoteTokenObj.decimals).toString(), - ); - - console.log('DEBUG: Raw amounts:'); - console.log(' - baseAmountRaw:', baseAmountRaw.toString()); - console.log(' - quoteAmountRaw:', quoteAmountRaw.toString()); - console.log(' - baseToken decimals:', baseTokenObj.decimals); - console.log(' - quoteToken decimals:', quoteTokenObj.decimals); - - // Create position from both amounts - if (isBaseToken0) { - console.log('DEBUG: Creating position with base as token0'); - position = Position.fromAmounts({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: baseAmountRaw.toString(), - amount1: quoteAmountRaw.toString(), - useFullPrecision: true, - }); - } else { - console.log('DEBUG: Creating position with base as token1'); - position = Position.fromAmounts({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: quoteAmountRaw.toString(), - amount1: baseAmountRaw.toString(), - useFullPrecision: true, - }); - } - - // Determine which token is limiting by comparing input vs required amounts - const baseRequired = isBaseToken0 ? position.amount0 : position.amount1; - const quoteRequired = isBaseToken0 ? position.amount1 : position.amount0; - - const baseRatio = parseFloat(baseAmountRaw.toString()) / parseFloat(baseRequired.quotient.toString()); - const quoteRatio = parseFloat(quoteAmountRaw.toString()) / parseFloat(quoteRequired.quotient.toString()); - - baseLimited = baseRatio <= quoteRatio; - } else if (baseTokenAmount !== undefined) { - console.log('DEBUG: Using fromAmount (only base amount provided)'); - // Only base amount provided - // Use parseUnits to avoid scientific notation issues with large numbers - const baseAmountRaw = JSBI.BigInt( - utils.parseUnits(baseTokenAmount.toString(), baseTokenObj.decimals).toString(), - ); - - console.log('DEBUG: baseAmountRaw:', baseAmountRaw.toString()); - - if (isBaseToken0) { - console.log('DEBUG: Creating position from amount0 (base is token0)'); - position = Position.fromAmount0({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: baseAmountRaw.toString(), - useFullPrecision: true, - }); - } else { - console.log('DEBUG: Creating position from amount1 (base is token1)'); - position = Position.fromAmount1({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount1: baseAmountRaw.toString(), - }); - } - baseLimited = true; - } else if (quoteTokenAmount !== undefined) { - console.log('DEBUG: Using fromAmount (only quote amount provided)'); - // Only quote amount provided - // Use parseUnits to avoid scientific notation issues with large numbers - const quoteAmountRaw = JSBI.BigInt( - utils.parseUnits(quoteTokenAmount.toString(), quoteTokenObj.decimals).toString(), - ); - - console.log('DEBUG: quoteAmountRaw:', quoteAmountRaw.toString()); - - if (isBaseToken0) { - console.log('DEBUG: Creating position from amount1 (quote is token1)'); - position = Position.fromAmount1({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount1: quoteAmountRaw.toString(), - }); - } else { - console.log('DEBUG: Creating position from amount0 (quote is token0)'); - position = Position.fromAmount0({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: quoteAmountRaw.toString(), - useFullPrecision: true, - }); - } - baseLimited = false; - } else { - throw httpErrors.badRequest('Either base or quote token amount must be provided'); - } - - // Calculate the optimal amounts - - // Get the actual token amounts from the position - const actualToken0Amount = position.amount0; - const actualToken1Amount = position.amount1; - - console.log('DEBUG: Position created with:'); - console.log(' - liquidity:', position.liquidity.toString()); - console.log(' - amount0 (raw):', actualToken0Amount.quotient.toString()); - console.log(' - amount1 (raw):', actualToken1Amount.quotient.toString()); - console.log(' - amount0 (formatted):', actualToken0Amount.toSignificant(18)); - console.log(' - amount1 (formatted):', actualToken1Amount.toSignificant(18)); - console.log(' - mintAmounts.amount0:', position.mintAmounts.amount0.toString()); - console.log(' - mintAmounts.amount1:', position.mintAmounts.amount1.toString()); - - // Calculate actual amounts in human-readable form - let actualBaseAmount, actualQuoteAmount; - - if (isBaseToken0) { - actualBaseAmount = parseFloat(actualToken0Amount.toSignificant(18)); - actualQuoteAmount = parseFloat(actualToken1Amount.toSignificant(18)); - } else { - actualBaseAmount = parseFloat(actualToken1Amount.toSignificant(18)); - actualQuoteAmount = parseFloat(actualToken0Amount.toSignificant(18)); - } - - console.log('DEBUG: Final amounts:'); - console.log(' - actualBaseAmount:', actualBaseAmount); - console.log(' - actualQuoteAmount:', actualQuoteAmount); - console.log(' - baseLimited:', baseLimited); - - // Calculate max amounts - const baseTokenAmountMax = baseTokenAmount || actualBaseAmount; - const quoteTokenAmountMax = quoteTokenAmount || actualQuoteAmount; - - // Calculate liquidity value - const liquidity = position.liquidity.toString(); - - // Use standard gas limit for position operations - const computeUnits = 500000; - - return { - baseLimited, - baseTokenAmount: actualBaseAmount, - quoteTokenAmount: actualQuoteAmount, - baseTokenAmountMax, - quoteTokenAmountMax, - liquidity, - computeUnits, - }; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to quote position'); - } - }, - ); -}; - -export default quotePositionRoute; // Export standalone function for use in unified routes export async function quotePosition( diff --git a/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts b/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts index 6eba0401fc..80048ffa29 100644 --- a/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts +++ b/src/connectors/pancakeswap/clmm-routes/quoteSwap.ts @@ -1,15 +1,9 @@ import { Token, CurrencyAmount, Percent, TradeType } from '@pancakeswap/sdk'; import { Route as V3Route, Trade as V3Trade } from '@pancakeswap/v3-sdk'; import { BigNumber, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - QuoteSwapRequestType, - QuoteSwapResponseType, - QuoteSwapRequest, - QuoteSwapResponse, -} from '../../../schemas/clmm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -272,124 +266,6 @@ async function formatSwapQuote( } } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - // Import the httpErrors plugin to ensure it's available - await fastify.register(require('@fastify/sensible')); - - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Pancakeswap V3 CLMM', - tags: ['/connector/pancakeswap'], - querystring: { - ...QuoteSwapRequest, - properties: { - ...QuoteSwapRequest.properties, - network: { type: 'string', default: 'bsc', examples: ['bsc'] }, - baseToken: { type: 'string', examples: ['USDT'] }, - quoteToken: { type: 'string', examples: ['WBNB'] }, - amount: { type: 'number', examples: [10] }, - side: { type: 'string', enum: ['BUY', 'SELL'], examples: ['SELL'] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, poolAddress, baseToken, quoteToken, amount, side, slippagePct } = request.query; - - const networkToUse = network; - - // Validate essential parameters - if (!baseToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, amount, and side are required'); - } - - const pancakeswap = await Pancakeswap.getInstance(networkToUse); - - let poolAddressToUse = poolAddress; - let baseTokenToUse: string; - let quoteTokenToUse: string; - - if (poolAddressToUse) { - // Pool address provided, get pool info to determine tokens - const poolInfo = await getPancakeswapPoolInfo(poolAddressToUse, networkToUse, 'clmm'); - if (!poolInfo) { - throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddressToUse)); - } - - // Determine which token is base and which is quote based on the provided baseToken - if (baseToken === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (baseToken === poolInfo.quoteTokenAddress) { - // User specified the quote token as base, so swap them - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - // Try to resolve baseToken as symbol to address - const resolvedToken = await pancakeswap.getToken(baseToken); - - if (resolvedToken) { - if (resolvedToken.address === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (resolvedToken.address === poolInfo.quoteTokenAddress) { - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } - } else { - // No pool address provided, need quoteToken to find pool - if (!quoteToken) { - throw httpErrors.badRequest('quoteToken is required when poolAddress is not provided'); - } - - baseTokenToUse = baseToken; - quoteTokenToUse = quoteToken; - - // Find pool using findDefaultPool - poolAddressToUse = await pancakeswap.findDefaultPool(baseTokenToUse, quoteTokenToUse, 'clmm'); - - if (!poolAddressToUse) { - throw httpErrors.notFound(`No CLMM pool found for pair ${baseTokenToUse}-${quoteTokenToUse}`); - } - } - - return await formatSwapQuote( - networkToUse, - poolAddressToUse, - baseTokenToUse, - quoteTokenToUse, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - logger.error('Unexpected error getting swap quote:', e); - throw httpErrors.internalServerError('Error getting swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Resolves the counter ("quote") token for a Pancakeswap V3 pool given the base token. The * standardized swap wrappers take poolAddress + baseToken and derive the other side from the pool, diff --git a/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts b/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts index 9487cb9f68..130c5103d7 100644 --- a/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts +++ b/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts @@ -1,22 +1,22 @@ import { Contract } from '@ethersproject/contracts'; import { Percent, CurrencyAmount } from '@pancakeswap/sdk'; -import { NonfungiblePositionManager, Position } from '@pancakeswap/v3-sdk'; +import { NonfungiblePositionManager, Position, computePoolAddress } from '@pancakeswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - RemoveLiquidityRequestType, - RemoveLiquidityRequest, - RemoveLiquidityResponseType, - RemoveLiquidityResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { slippageBasisPoints } from '../../evm-slippage'; import { Pancakeswap } from '../pancakeswap'; -import { POSITION_MANAGER_ABI, getPancakeswapV3NftManagerAddress } from '../pancakeswap.contracts'; +import { PancakeswapConfig } from '../pancakeswap.config'; +import { + POSITION_MANAGER_ABI, + getPancakeswapV3NftManagerAddress, + getPancakeswapV3PoolDeployerAddress, +} from '../pancakeswap.contracts'; import { formatTokenAmount } from '../pancakeswap.utils'; // Default gas limit for CLMM remove liquidity operations @@ -27,6 +27,7 @@ export async function removeLiquidity( walletAddress: string, positionAddress: string, percentageToRemove: number, + slippagePct: number = PancakeswapConfig.config.slippagePct, ): Promise { if (!positionAddress || percentageToRemove === undefined) { throw httpErrors.badRequest('Missing required parameters'); @@ -60,6 +61,16 @@ export async function removeLiquidity( const token0 = await pancakeswap.getToken(position.token0); const token1 = await pancakeswap.getToken(position.token1); + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + deployerAddress: getPancakeswapV3PoolDeployerAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); + const isBaseToken0 = token0.symbol === 'WETH' || (token1.symbol !== 'WETH' && token0.address.toLowerCase() < token1.address.toLowerCase()); @@ -90,7 +101,10 @@ export async function removeLiquidity( const amount0 = partialPosition.amount0; const amount1 = partialPosition.amount1; - const slippageTolerance = new Percent(100, 10000); + // The caller's tolerance, or the connector's configured one — not a literal. This was + // `new Percent(100, 10000)`, a flat 1% that ignored both, so an operator who had widened + // slippagePct for a volatile pair got 1% anyway and a revert that cost gas. + const slippageTolerance = new Percent(slippageBasisPoints(slippagePct), 10000); const totalAmount0 = CurrencyAmount.fromRawAmount( token0, @@ -133,9 +147,12 @@ export async function removeLiquidity( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_REMOVE_LIQUIDITY_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); const token1AmountRemoved = formatTokenAmount(totalAmount1.quotient.toString(), token1.decimals); @@ -143,77 +160,13 @@ export async function removeLiquidity( const quoteTokenAmountRemoved = isBaseToken0 ? token1AmountRemoved : token0AmountRemoved; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, }; } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: RemoveLiquidityRequestType; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a Pancakeswap V3 position', - tags: ['/connector/pancakeswap'], - body: { - ...RemoveLiquidityRequest, - properties: { - ...RemoveLiquidityRequest.properties, - network: { type: 'string', default: 'bsc', examples: ['bsc'] }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - positionAddress: { - type: 'string', - description: 'Position NFT token ID', - examples: ['1234'], - }, - percentageToRemove: { - type: 'number', - minimum: 0, - maximum: 100, - examples: [50], - }, - }, - }, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress: requestedWalletAddress, positionAddress, percentageToRemove } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const pancakeswap = await Pancakeswap.getInstance(network); - walletAddress = await pancakeswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await removeLiquidity(network, walletAddress, positionAddress, percentageToRemove); - } catch (e: any) { - logger.error('Failed to remove liquidity:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to remove liquidity'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/pancakeswap/pancakeswap.routes.ts b/src/connectors/pancakeswap/pancakeswap.routes.ts deleted file mode 100644 index 2be31c8386..0000000000 --- a/src/connectors/pancakeswap/pancakeswap.routes.ts +++ /dev/null @@ -1,61 +0,0 @@ -import sensible from '@fastify/sensible'; -import { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { pancakeswapAmmRoutes } from './amm-routes'; -import { pancakeswapClmmRoutes } from './clmm-routes'; -import { pancakeswapRouterRoutes } from './router-routes'; - -// Router routes (Universal Router with 4 endpoints) -const pancakeswapRouterRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/pancakeswap']; - } - }); - - await instance.register(pancakeswapRouterRoutes); - }); -}; - -// AMM routes (Pancakeswap V2) -const pancakeswapAmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/pancakeswap']; - } - }); - - await instance.register(pancakeswapAmmRoutes); - }); -}; - -// CLMM routes (Pancakeswap V3) -const pancakeswapClmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/pancakeswap']; - } - }); - - await instance.register(pancakeswapClmmRoutes); - }); -}; - -// Export routes in the same pattern as other connectors -export const pancakeswapRoutes = { - router: pancakeswapRouterRoutesWrapper, - amm: pancakeswapAmmRoutesWrapper, - clmm: pancakeswapClmmRoutesWrapper, -}; - -export default pancakeswapRoutes; diff --git a/src/connectors/pancakeswap/pancakeswap.utils.ts b/src/connectors/pancakeswap/pancakeswap.utils.ts index 85de802e8c..636bd49685 100644 --- a/src/connectors/pancakeswap/pancakeswap.utils.ts +++ b/src/connectors/pancakeswap/pancakeswap.utils.ts @@ -2,7 +2,6 @@ import { Contract } from '@ethersproject/contracts'; import { Token } from '@pancakeswap/sdk'; import { FeeAmount, Pool as V3Pool } from '@pancakeswap/v3-sdk'; import { FastifyInstance } from 'fastify'; -import { Address } from 'viem'; import { Ethereum } from '../../chains/ethereum/ethereum'; import { logger } from '../../services/logger'; diff --git a/src/connectors/pancakeswap/router-routes/executeQuote.ts b/src/connectors/pancakeswap/router-routes/executeQuote.ts index cbb6ae95cf..23251c5d2b 100644 --- a/src/connectors/pancakeswap/router-routes/executeQuote.ts +++ b/src/connectors/pancakeswap/router-routes/executeQuote.ts @@ -1,14 +1,11 @@ import { BigNumber, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; -import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; -import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; -import { PancakeswapExecuteQuoteRequest } from '../schemas'; async function executeQuote(walletAddress: string, network: string, quoteId: string): Promise { // Retrieve cached quote @@ -18,7 +15,7 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str } const { quote, request } = cached; - const { inputToken, outputToken, side, amount } = request; + const { inputToken, outputToken, side, amount, slippagePct } = request; const ethereum = await Ethereum.getInstance(network); @@ -179,6 +176,8 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str expectedAmountIn, expectedAmountOut, side, + undefined, + slippagePct, ); // Handle different transaction states @@ -208,37 +207,3 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str } export { executeQuote }; - -export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteQuoteRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-quote', - { - schema: { - description: 'Execute a previously fetched quote from Pancakeswap Universal Router', - tags: ['/connector/pancakeswap'], - body: PancakeswapExecuteQuoteRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { - walletAddress = getEthereumChainConfig().defaultWallet, - network = getEthereumChainConfig().defaultNetwork, - quoteId, - } = request.body as typeof PancakeswapExecuteQuoteRequest._type; - - return await executeQuote(walletAddress, network, quoteId); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeQuoteRoute; diff --git a/src/connectors/pancakeswap/router-routes/executeSwap.ts b/src/connectors/pancakeswap/router-routes/executeSwap.ts index b5879d5531..f421f3f4cb 100644 --- a/src/connectors/pancakeswap/router-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/router-routes/executeSwap.ts @@ -1,10 +1,6 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { logger } from '../../../services/logger'; import { PancakeswapConfig } from '../pancakeswap.config'; -import { PancakeswapExecuteSwapRequest } from '../schemas'; // Import the quote and execute functions import { executeQuote } from './executeQuote'; @@ -31,42 +27,3 @@ async function executeSwap( } export { executeSwap }; - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Quote and execute a token swap on Pancakeswap Universal Router in one step', - tags: ['/connector/pancakeswap'], - body: PancakeswapExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct } = - request.body as typeof PancakeswapExecuteSwapRequest._type; - - return await executeSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/pancakeswap/router-routes/index.ts b/src/connectors/pancakeswap/router-routes/index.ts deleted file mode 100644 index cef71a037c..0000000000 --- a/src/connectors/pancakeswap/router-routes/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import executeQuoteRoute from './executeQuote'; -import executeSwapRoute from './executeSwap'; -import quoteSwapRoute from './quoteSwap'; - -export const pancakeswapRouterRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(quoteSwapRoute); - await fastify.register(executeQuoteRoute); - await fastify.register(executeSwapRoute); -}; - -export default pancakeswapRouterRoutes; diff --git a/src/connectors/pancakeswap/router-routes/quoteSwap.ts b/src/connectors/pancakeswap/router-routes/quoteSwap.ts index d860a2c4ee..59d5a2dcd5 100644 --- a/src/connectors/pancakeswap/router-routes/quoteSwap.ts +++ b/src/connectors/pancakeswap/router-routes/quoteSwap.ts @@ -1,18 +1,14 @@ import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; -import { QuoteSwapRequestType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Pancakeswap } from '../pancakeswap'; import { PancakeswapConfig } from '../pancakeswap.config'; -import { PancakeswapQuoteSwapRequest, PancakeswapQuoteSwapResponse } from '../schemas'; - +import { PancakeswapQuoteSwapResponse } from '../schemas'; async function quoteSwap( network: string, walletAddress: string | undefined, @@ -136,51 +132,3 @@ async function quoteSwap( } export { quoteSwap }; - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - const chainConfig = getEthereumChainConfig(); - - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: Static; - }>( - '/quote-swap', - { - schema: { - description: 'Get an executable swap quote from Pancakeswap Universal Router', - tags: ['/connector/pancakeswap'], - querystring: PancakeswapQuoteSwapRequest, - response: { 200: PancakeswapQuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { - network = chainConfig.defaultNetwork, - walletAddress = chainConfig.defaultWallet, - baseToken, - quoteToken, - amount, - side, - slippagePct, - } = request.query as typeof PancakeswapQuoteSwapRequest._type; - - return await quoteSwap( - network, - walletAddress, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error getting quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/connectors/pancakeswap/schemas.ts b/src/connectors/pancakeswap/schemas.ts index 7d01b9a1cf..fb764f5b79 100644 --- a/src/connectors/pancakeswap/schemas.ts +++ b/src/connectors/pancakeswap/schemas.ts @@ -1,213 +1,21 @@ import { Type } from '@sinclair/typebox'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; - -import { PancakeswapConfig } from './pancakeswap.config'; - // Get chain config for defaults -const ethereumChainConfig = getEthereumChainConfig(); - // Constants for examples -const BASE_TOKEN = 'USDT'; -const QUOTE_TOKEN = 'WBNB'; -const SWAP_AMOUNT = 10; -const AMM_POOL_ADDRESS_EXAMPLE = '0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C'; // Pancakeswap V2 WETH-USDC pool on Base -const CLMM_POOL_ADDRESS_EXAMPLE = '0x172fcd41e0913e95784454622d1c3724f546f849'; // Pancakeswap V3 USDT-WBNB pool on BSC +// Pancakeswap V2 WETH-USDC pool on Base // ======================================== // AMM Request Schemas // ======================================== -export const PancakeswapAmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Pancakeswap V2 pool address', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), -}); - -// Pancakeswap AMM Create Pool Request (Pancakeswap V2 — Uniswap V2 fork, fixed 0.25% fee) -export const PancakeswapAmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will create and seed the pool', - default: ethereumChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to seed the pool with', - }), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: - 'Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. ' + - 'If omitted (and no initialPrice), the current market price is fetched from the unified swap router.', - }), - ), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the pool is ' + - 'seeded at the current market price so it is not immediately arbitraged.', - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - // ======================================== // CLMM Request Schemas // ======================================== -export const PancakeswapClmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Pancakeswap V3 pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), -}); - -// Pancakeswap CLMM Create Pool Request (Pancakeswap V3 — Uniswap V3 fork) -export const PancakeswapClmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will create and initialize the pool', - default: ethereumChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - fee: Type.Number({ - description: - 'Pancakeswap V3 fee tier in hundredths of a bip: 100 (0.01%), 500 (0.05%), 2500 (0.25%), or 10000 (1.00%)', - enum: [100, 500, 2500, 10000], - examples: [2500], - }), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. If omitted, the current market price is fetched from the ' + - 'unified swap router so the pool opens on-market and is not immediately arbitraged.', - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [600000], - }), - ), -}); - // ======================================== // Router Request Schemas // ======================================== -// Pancakeswap-specific quote-swap request -export const PancakeswapQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'First token in the trading pair', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Second token in the trading pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address for more accurate quotes (optional)', - default: ethereumChainConfig.defaultWallet, - }), - ), -}); - // Pancakeswap-specific quote-swap response export const PancakeswapQuoteSwapResponse = Type.Object({ quoteId: Type.String({ @@ -243,462 +51,3 @@ export const PancakeswapQuoteSwapResponse = Type.Object({ }), ), }); - -// Pancakeswap-specific execute-quote request -export const PancakeswapExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - examples: [ethereumChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), -}); - -// Pancakeswap AMM Add Liquidity Request -export const PancakeswapAmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will add liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Address of the Pancakeswap V2 pool', - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Pancakeswap AMM Remove Liquidity Request -export const PancakeswapAmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will remove liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Address of the Pancakeswap V2 pool', - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Pancakeswap AMM Execute Swap Request -export const PancakeswapAmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Pool address (optional - can be looked up from tokens)', - default: '', - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - }), - ), -}); - -// Pancakeswap-specific execute-swap request -export const PancakeswapExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - examples: [ethereumChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...PancakeswapConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other token in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - examples: [1], - }), - ), -}); - -// Pancakeswap CLMM Open Position Request -export const PancakeswapClmmOpenPositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will open the position', - default: ethereumChainConfig.defaultWallet, - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - }), - poolAddress: Type.String({ - description: 'Address of the Pancakeswap V3 pool', - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Pancakeswap CLMM Add Liquidity Request -export const PancakeswapClmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will add liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Pancakeswap CLMM Remove Liquidity Request -export const PancakeswapClmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will remove liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Pancakeswap CLMM Close Position Request -export const PancakeswapClmmClosePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will close the position', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position to close', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Pancakeswap CLMM Collect Fees Request -export const PancakeswapClmmCollectFeesRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will collect fees', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Pancakeswap CLMM Execute Swap Request -export const PancakeswapClmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: 'bsc', - examples: ['bsc'], - enum: [...PancakeswapConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Pool address (optional - can be looked up from tokens)', - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: PancakeswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); diff --git a/src/connectors/raydium/amm-routes/addLiquidity.ts b/src/connectors/raydium/amm-routes/addLiquidity.ts index 33ed4a8213..16ddb0ab4b 100644 --- a/src/connectors/raydium/amm-routes/addLiquidity.ts +++ b/src/connectors/raydium/amm-routes/addLiquidity.ts @@ -7,23 +7,16 @@ import { TokenAmount, toToken, } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { VersionedTransaction, Transaction, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { - AddLiquidityResponse, - AddLiquidityResponseType, - QuoteLiquidityResponseType, -} from '../../../schemas/amm-schema'; +import { AddLiquidityResponseType, QuoteLiquidityResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumAmmAddLiquidityRequest } from '../schemas'; import { quoteLiquidity } from './quoteLiquidity'; @@ -141,13 +134,7 @@ export async function addLiquidity( slippagePct, )) as QuoteLiquidityResponseType; - const { - baseLimited, - baseTokenAmount: quotedBaseAmount, - quoteTokenAmount: quotedQuoteAmount, - baseTokenAmountMax, - quoteTokenAmountMax, - } = quoteResponse; + const { baseLimited, baseTokenAmount: quotedBaseAmount, quoteTokenAmount: quotedQuoteAmount } = quoteResponse; const baseTokenAmountAdded = baseLimited ? baseTokenAmount : quotedBaseAmount; const quoteTokenAmountAdded = baseLimited ? quotedQuoteAmount : quoteTokenAmount; @@ -188,10 +175,9 @@ export async function addLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (confirmed && txData) { @@ -210,8 +196,13 @@ export async function addLiquidity( status: 1, // CONFIRMED data: { fee: txData.meta.fee / 1e9, - baseTokenAmountAdded: baseTokenBalanceChange, - quoteTokenAmountAdded: quoteTokenBalanceChange, + // Magnitudes, not the raw wallet delta. A deposit moves tokens out, so the + // signed change is negative and a field named `…Added` would report a negative + // deposit — which is what every consumer summing these rows then has to guess + // about. Every other connector, and the whole removed side including Raydium's + // own, reports magnitudes. + baseTokenAmountAdded: Math.abs(baseTokenBalanceChange), + quoteTokenAmountAdded: Math.abs(quoteTokenBalanceChange), }, }; } else { @@ -221,37 +212,3 @@ export async function addLiquidity( }; } } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - // const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to a Raydium AMM/CPMM pool', - tags: ['/connector/raydium'], - body: RaydiumAmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.body; - - return await addLiquidity(network, walletAddress, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/raydium/amm-routes/createPool.ts b/src/connectors/raydium/amm-routes/createPool.ts index 39a9d54a4c..7e3f28f7a3 100644 --- a/src/connectors/raydium/amm-routes/createPool.ts +++ b/src/connectors/raydium/amm-routes/createPool.ts @@ -5,20 +5,17 @@ import { DEV_CREATE_CPMM_POOL_PROGRAM, DEV_CREATE_CPMM_POOL_FEE_ACC, } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getMint } from '@solana/spl-token'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Raydium } from '../raydium'; -import { RaydiumAmmCreatePoolRequest } from '../schemas'; /** Resolves a token symbol or mint address to a PublicKey. */ async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { @@ -53,10 +50,10 @@ async function fetchMarketPrice( quoteToken: string, amount: number, ): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, amount, 'SELL'); + quote = await getSwapQuote(`solana-${network}`, baseToken, quoteToken, amount, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + @@ -186,10 +183,9 @@ export async function createPool( logger.info(`Creating Raydium CPMM pool ${poolAddress} (${baseToken}/${quoteToken})`); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ @@ -210,54 +206,3 @@ export async function createPool( } return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: 'Create a new Raydium CPMM (CP-Swap) pool and seed it with initial liquidity', - tags: ['/connector/raydium'], - body: RaydiumAmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - initialPrice, - feeConfigIndex, - openTime, - } = request.body; - return await createPool( - network, - walletAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - initialPrice, - feeConfigIndex, - openTime, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/raydium/amm-routes/executeSwap.ts b/src/connectors/raydium/amm-routes/executeSwap.ts index 273e56f1c4..c649d95c07 100644 --- a/src/connectors/raydium/amm-routes/executeSwap.ts +++ b/src/connectors/raydium/amm-routes/executeSwap.ts @@ -1,15 +1,13 @@ import { PublicKey, VersionedTransaction } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ExecuteSwapResponse, ExecuteSwapResponseType, ExecuteSwapRequestType } from '../../../schemas/amm-schema'; +import { ExecuteSwapResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumAmmExecuteSwapRequest } from '../schemas'; import { getRawSwapQuote } from './quoteSwap'; @@ -156,20 +154,19 @@ export async function executeSwap( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); // Handle confirmation status const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, side, + effectiveSlippage, ); if (result.status === 1) { @@ -180,94 +177,3 @@ export async function executeSwap( return result as ExecuteSwapResponseType; } - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on Raydium AMM or CPMM', - tags: ['/connector/raydium'], - body: { - ...RaydiumAmmExecuteSwapRequest, - properties: { - ...RaydiumAmmExecuteSwapRequest.properties, - network: { type: 'string', default: 'mainnet-beta' }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - baseToken: { type: 'string', examples: ['SOL'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - amount: { type: 'number', examples: [0.01] }, - side: { type: 'string', examples: ['SELL'] }, - poolAddress: { type: 'string', examples: [''] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { 200: ExecuteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, walletAddress, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = - request.body as typeof RaydiumAmmExecuteSwapRequest._type; - const networkToUse = network; - - // If no pool address provided, find default pool - let poolAddressToUse = poolAddress; - if (!poolAddressToUse) { - const solana = await Solana.getInstance(networkToUse); - - // Resolve token symbols to get proper symbols for pool lookup - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest( - sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken), - ); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'raydium', - networkToUse, - 'amm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No AMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Raydium`, - ); - } - - poolAddressToUse = pool.address; - } - - return await executeSwap( - networkToUse, - walletAddress, - poolAddressToUse, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Swap execution failed'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/raydium/amm-routes/index.ts b/src/connectors/raydium/amm-routes/index.ts deleted file mode 100644 index a9071694b3..0000000000 --- a/src/connectors/raydium/amm-routes/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { addLiquidityRoute } from './addLiquidity'; -import { createPoolRoute } from './createPool'; -import { executeSwapRoute } from './executeSwap'; -import { poolInfoRoute } from './poolInfo'; -import { positionInfoRoute } from './positionInfo'; -import { quoteLiquidityRoute } from './quoteLiquidity'; -import { quoteSwapRoute } from './quoteSwap'; -import { removeLiquidityRoute } from './removeLiquidity'; - -export const raydiumAmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionInfoRoute); - await fastify.register(quoteLiquidityRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(executeSwapRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(createPoolRoute); -}; - -export default raydiumAmmRoutes; diff --git a/src/connectors/raydium/amm-routes/poolInfo.ts b/src/connectors/raydium/amm-routes/poolInfo.ts index 23669b496c..c0c3a57953 100644 --- a/src/connectors/raydium/amm-routes/poolInfo.ts +++ b/src/connectors/raydium/amm-routes/poolInfo.ts @@ -1,10 +1,6 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { PoolInfo } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; -import { RaydiumAmmGetPoolInfoRequest } from '../schemas'; /** * Standardized network-first pool-info fetcher for the Raydium AMM/CPMM connector. @@ -20,34 +16,3 @@ export async function getPoolInfo(network: string, poolAddress: string): Promise const { poolType, ...basePoolInfo } = poolInfo; return basePoolInfo; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPoolInfoRequestType; - Reply: Record; - }>( - '/pool-info', - { - schema: { - description: 'Get AMM pool information from Raydium', - tags: ['/connector/raydium'], - querystring: RaydiumAmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress, network } = request.query; - return await getPoolInfo(network, poolAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to fetch pool info'); - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/raydium/amm-routes/positionInfo.ts b/src/connectors/raydium/amm-routes/positionInfo.ts index 1b7de916fd..cc8b02274e 100644 --- a/src/connectors/raydium/amm-routes/positionInfo.ts +++ b/src/connectors/raydium/amm-routes/positionInfo.ts @@ -1,14 +1,9 @@ -import { BN } from '@coral-xyz/anchor'; import { PublicKey } from '@solana/web3.js'; -import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { PositionInfo, PositionInfoSchema, GetPositionInfoRequestType } from '../../../schemas/amm-schema'; +import { PositionInfo } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; -import { RaydiumAmmGetPositionInfoRequest } from '../schemas'; /** * Calculate the LP token amount and corresponding token amounts @@ -105,7 +100,7 @@ export async function getPositionInfo( // Get pool info const ammPoolInfo = await raydium.getAmmPoolInfo(poolAddress); - const [poolInfo, poolKeys] = await raydium.getPoolfromAPI(poolAddress); + const [poolInfo] = await raydium.getPoolfromAPI(poolAddress); if (!poolInfo) { throw httpErrors.notFound('Pool not found'); } @@ -130,36 +125,3 @@ export async function getPositionInfo( price: poolInfo.price, }; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get info about a Raydium AMM position', - tags: ['/connector/raydium'], - querystring: RaydiumAmmGetPositionInfoRequest, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { poolAddress, walletAddress } = request.query; - const network = request.query.network; - - return await getPositionInfo(network, poolAddress, walletAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to fetch position info'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/raydium/amm-routes/quoteLiquidity.ts b/src/connectors/raydium/amm-routes/quoteLiquidity.ts index 50f886ee07..2d09f699d3 100644 --- a/src/connectors/raydium/amm-routes/quoteLiquidity.ts +++ b/src/connectors/raydium/amm-routes/quoteLiquidity.ts @@ -5,19 +5,13 @@ import { TokenAmount, } from '@raydium-io/raydium-sdk-v2'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { - QuoteLiquidityRequestType, - QuoteLiquidityResponse, - QuoteLiquidityResponseType, -} from '../../../schemas/amm-schema'; +import { QuoteLiquidityResponseType } from '../../../schemas/amm-schema'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; import { isValidAmm, isValidCpmm } from '../raydium.utils'; -import { RaydiumAmmQuoteLiquidityRequest } from '../schemas'; interface AmmComputePairResult { anotherAmount: TokenAmount; @@ -64,7 +58,7 @@ export async function quoteLiquidity( const solana = await Solana.getInstance(network); const raydium = await Raydium.getInstance(network); - const [poolInfo, poolKeys] = await raydium.getPoolfromAPI(poolAddress); + const [poolInfo] = await raydium.getPoolfromAPI(poolAddress); const programId = poolInfo.programId; if (!isValidAmm(programId) && !isValidCpmm(programId)) { @@ -209,39 +203,3 @@ export async function quoteLiquidity( throw error; } } - -export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteLiquidityRequestType; - Reply: QuoteLiquidityResponseType | { error: string }; - }>( - '/quote-liquidity', - { - schema: { - description: 'Quote amounts for a new Raydium AMM liquidity position', - tags: ['/connector/raydium'], - querystring: RaydiumAmmQuoteLiquidityRequest, - response: { - 200: QuoteLiquidityResponse, - 500: { - type: 'object', - properties: { error: { type: 'string' } }, - }, - }, - }, - }, - async (request) => { - try { - const { network = 'mainnet-beta', poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; - - return await quoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to quote position'); - } - }, - ); -}; - -export default quoteLiquidityRoute; diff --git a/src/connectors/raydium/amm-routes/quoteSwap.ts b/src/connectors/raydium/amm-routes/quoteSwap.ts index bd56d36f6a..de750d2170 100644 --- a/src/connectors/raydium/amm-routes/quoteSwap.ts +++ b/src/connectors/raydium/amm-routes/quoteSwap.ts @@ -2,17 +2,14 @@ import { ApiV3PoolInfoStandardItem, ApiV3PoolInfoStandardItemCpmm, CurveCalculat import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import Decimal from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; -import { estimateGasSolana } from '../../../chains/solana/routes/estimate-gas'; import { Solana } from '../../../chains/solana/solana'; -import { QuoteSwapResponseType, QuoteSwapResponse, QuoteSwapRequestType } from '../../../schemas/amm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumAmmQuoteSwapRequest } from '../schemas'; async function quoteAmmSwap( raydium: Raydium, @@ -25,14 +22,12 @@ async function quoteAmmSwap( slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { let poolInfo: ApiV3PoolInfoStandardItem; - let poolKeys: any; let rpcData: any; if (network === 'mainnet-beta') { // note: api doesn't support get devnet pool info, so in devnet else we go rpc method - const [poolInfoData, poolKeysData] = await raydium.getPoolfromAPI(poolId); + const [poolInfoData] = await raydium.getPoolfromAPI(poolId); poolInfo = poolInfoData as ApiV3PoolInfoStandardItem; - poolKeys = poolKeysData; rpcData = await raydium.raydiumSDK.liquidity.getRpcPoolInfo(poolId); } else { // note: getPoolInfoFromRpc method only returns required pool data for computing not all detail pool info @@ -40,7 +35,6 @@ async function quoteAmmSwap( poolId, }); poolInfo = data.poolInfo; - poolKeys = data.poolKeys; rpcData = data.poolRpcData; } @@ -124,18 +118,15 @@ async function quoteCpmmSwap( slippagePct: number = RaydiumConfig.config.slippagePct, ): Promise { let poolInfo: ApiV3PoolInfoStandardItemCpmm; - let poolKeys: any; let rpcData: any; if (network === 'mainnet-beta') { - const [poolInfoData, poolKeysData] = await raydium.getPoolfromAPI(poolId); + const [poolInfoData] = await raydium.getPoolfromAPI(poolId); poolInfo = poolInfoData as ApiV3PoolInfoStandardItemCpmm; - poolKeys = poolKeysData; rpcData = await raydium.raydiumSDK.cpmm.getRpcPoolInfo(poolInfo.id, true); } else { const data = await raydium.raydiumSDK.cpmm.getPoolInfoFromRpc(poolId); poolInfo = data.poolInfo; - poolKeys = data.poolKeys; rpcData = data.rpcData; } @@ -462,7 +453,6 @@ async function formatSwapQuote( const tokenOut = side === 'SELL' ? resolvedQuoteToken.address : resolvedBaseToken.address; // Calculate fee and price impact - const fee = quote.fee ? new Decimal(quote.fee.toString()).div(10 ** inputToken.decimals).toNumber() : 0; const priceImpactPct = quote.priceImpact ? quote.priceImpact * 100 : 0; return { @@ -480,106 +470,6 @@ async function formatSwapQuote( }; } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Raydium AMM', - tags: ['/connector/raydium'], - querystring: RaydiumAmmQuoteSwapRequest, - response: { - 200: QuoteSwapResponse, - }, - }, - }, - async (request) => { - try { - const { network, poolAddress, baseToken, quoteToken, amount, side, slippagePct } = request.query; - const networkToUse = network; - - // Validate essential parameters - if (!baseToken || !quoteToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, quoteToken, amount, and side are required'); - } - - const raydium = await Raydium.getInstance(networkToUse); - const solana = await Solana.getInstance(networkToUse); - - let poolAddressToUse = poolAddress; - - // If poolAddress is not provided, look it up by token pair - if (!poolAddressToUse) { - // Resolve token symbols to get proper symbols for pool lookup - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest( - sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken), - ); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'raydium', - networkToUse, - 'amm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No AMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Raydium`, - ); - } - - poolAddressToUse = pool.address; - } - - const result = await quoteSwap( - networkToUse, - poolAddressToUse, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - - let gasEstimation = null; - try { - gasEstimation = await estimateGasSolana(networkToUse); - } catch (error) { - logger.warn(`Failed to estimate gas for swap quote: ${error.message}`); - } - - return result; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - if (e.message?.includes('Pool not found')) { - throw httpErrors.notFound(e.message); - } - if (e.message?.includes('Token not found')) { - throw httpErrors.badRequest(e.message); - } - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Standardized network-first swap quote for the Raydium AMM/CPMM connector. * `amount` is denominated in the base token; the counter ("quote") token is derived from the pool. diff --git a/src/connectors/raydium/amm-routes/removeLiquidity.ts b/src/connectors/raydium/amm-routes/removeLiquidity.ts index 806801bcc3..43d07e067b 100644 --- a/src/connectors/raydium/amm-routes/removeLiquidity.ts +++ b/src/connectors/raydium/amm-routes/removeLiquidity.ts @@ -5,19 +5,16 @@ import { ApiV3PoolInfoStandardItemCpmm, Percent, } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { VersionedTransaction, Transaction, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumAmmRemoveLiquidityRequest } from '../schemas'; // Interfaces for SDK responses interface TokenBurnInfo { @@ -189,10 +186,9 @@ export async function removeLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (confirmed && txData) { @@ -227,37 +223,3 @@ export async function removeLiquidity( }; } } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - // const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a Raydium AMM/CPMM pool', - tags: ['/connector/raydium'], - body: RaydiumAmmRemoveLiquidityRequest, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, poolAddress, percentageToRemove } = request.body; - - return await removeLiquidity(network, walletAddress, poolAddress, percentageToRemove); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/raydium/clmm-routes/addLiquidity.ts b/src/connectors/raydium/clmm-routes/addLiquidity.ts index f697aaef23..8af55f635e 100644 --- a/src/connectors/raydium/clmm-routes/addLiquidity.ts +++ b/src/connectors/raydium/clmm-routes/addLiquidity.ts @@ -1,16 +1,12 @@ import { TxVersion } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas/clmm-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { AddLiquidityResponseType } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumClmmAddLiquidityRequest } from '../schemas'; import { quotePosition } from './quotePosition'; @@ -34,7 +30,7 @@ export async function addLiquidity( const position = await raydium.getClmmPosition(positionAddress); if (!position) throw new Error('Position not found'); - const [poolInfo, poolKeys] = await raydium.getClmmPoolfromAPI(positionInfo.poolAddress); + const [poolInfo] = await raydium.getClmmPoolfromAPI(positionInfo.poolAddress); // const clmmPool = await raydium.getClmmPoolfromRPC(positionInfo.poolAddress); const baseToken = await solana.getToken(poolInfo.mintA.address); @@ -49,7 +45,6 @@ export async function addLiquidity( quoteTokenAmount, slippagePct, ); - console.log('quotePositionResponse', quotePositionResponse); logger.info('Adding liquidity to Raydium CLMM position...'); // Use hardcoded compute units for add liquidity @@ -81,10 +76,9 @@ export async function addLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (confirmed && txData) { @@ -112,7 +106,6 @@ export async function addLiquidity( const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, tokenAddresses); // Parse balance changes - const solChangeIndex = 0; const baseChangeIndex = isBaseSol ? 0 : 1; const quoteChangeIndex = isQuoteSol ? 0 : isBaseSol ? 1 : 2; @@ -123,9 +116,16 @@ export async function addLiquidity( signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: position.poolId.toBase58(), fee: totalFee / 1e9, - baseTokenAmountAdded: baseTokenBalanceChange, - quoteTokenAmountAdded: quoteTokenBalanceChange, + // Magnitudes, as everywhere else: a deposit's signed wallet delta is negative, + // and `…Added` naming a negative number is wrong at the source. Adding to an + // existing position locks no new rent, so there is nothing to back out. + baseTokenAmountAdded: Math.abs(baseTokenBalanceChange), + quoteTokenAmountAdded: Math.abs(quoteTokenBalanceChange), }, }; } else { @@ -136,43 +136,3 @@ export async function addLiquidity( }; } } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to existing Raydium CLMM position', - tags: ['/connector/raydium'], - body: RaydiumClmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, positionAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = - request.body; - - return await addLiquidity( - network, - walletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/raydium/clmm-routes/closePosition.ts b/src/connectors/raydium/clmm-routes/closePosition.ts index 8336fe294f..fc46d849be 100644 --- a/src/connectors/raydium/clmm-routes/closePosition.ts +++ b/src/connectors/raydium/clmm-routes/closePosition.ts @@ -1,14 +1,12 @@ import { TxVersion } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ClosePositionResponse, ClosePositionResponseType } from '../../../schemas/clmm-schema'; +import { liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { ClosePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; -import { RaydiumClmmClosePositionRequest } from '../schemas'; import { removeLiquidity } from './removeLiquidity'; @@ -39,24 +37,34 @@ export async function closePosition( if (removeLiquidityResponse.status === 1 && removeLiquidityResponse.data) { // Use the new helper to extract balance changes including SOL handling - const { baseTokenChange, quoteTokenChange, rent } = await solana.extractClmmBalanceChanges( + const { baseTokenChange, quoteTokenChange, rent, accountSol } = await solana.extractClmmBalanceChanges( removeLiquidityResponse.signature, walletAddress, baseTokenInfo, quoteTokenInfo, - removeLiquidityResponse.data.fee * 1e9, ); - // The total balance change includes both liquidity removal and fee collection - // Since we know the liquidity amounts from removeLiquidity response, - // we can calculate the fee amounts - const baseFeeCollected = Math.abs(baseTokenChange) - removeLiquidityResponse.data.baseTokenAmountRemoved; - const quoteFeeCollected = Math.abs(quoteTokenChange) - removeLiquidityResponse.data.quoteTokenAmountRemoved; + // The total balance change includes both liquidity removal and fee collection. + // Since we know the liquidity amounts from the removeLiquidity response, the fee + // is the difference — but only if both sides are measured the same way. The + // removal already took the closed accounts' lamports off its native side, so this + // one has to as well; measuring one net of rent against the other gross of it + // would book the rent as fee income. + const baseFeeCollected = + liquidityWithoutRent(baseTokenChange, new PublicKey(baseTokenInfo.address), accountSol) - + removeLiquidityResponse.data.baseTokenAmountRemoved; + const quoteFeeCollected = + liquidityWithoutRent(quoteTokenChange, new PublicKey(quoteTokenInfo.address), accountSol) - + removeLiquidityResponse.data.quoteTokenAmountRemoved; return { signature: removeLiquidityResponse.signature, status: removeLiquidityResponse.status, data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: position.poolId.toBase58(), fee: removeLiquidityResponse.data.fee, positionRentRefunded: rent, baseTokenAmountRemoved: removeLiquidityResponse.data.baseTokenAmountRemoved, @@ -102,10 +110,9 @@ export async function closePosition( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(result.transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (!confirmed || !txData) { @@ -123,6 +130,9 @@ export async function closePosition( signature, status: 1, // CONFIRMED data: { + // Same pool as the branch above — this is the already-empty position path, which + // closes the account without a withdrawal, so it must name its venue too. + poolAddress: position.poolId.toBase58(), fee, positionRentRefunded: rentRefunded, baseTokenAmountRemoved: 0, @@ -136,40 +146,3 @@ export async function closePosition( throw error; } } - -export const closePositionRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: ClosePositionResponseType; - }>( - '/close-position', - { - schema: { - description: 'Close a Raydium CLMM position', - tags: ['/connector/raydium'], - body: RaydiumClmmClosePositionRequest, - response: { - 200: ClosePositionResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, positionAddress } = request.body; - const networkToUse = network; - - return await closePosition(networkToUse, walletAddress, positionAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default closePositionRoute; diff --git a/src/connectors/raydium/clmm-routes/collectFees.ts b/src/connectors/raydium/clmm-routes/collectFees.ts index fd26cbe2a3..f11b301873 100644 --- a/src/connectors/raydium/clmm-routes/collectFees.ts +++ b/src/connectors/raydium/clmm-routes/collectFees.ts @@ -1,19 +1,22 @@ +import { TxVersion } from '@raydium-io/raydium-sdk-v2'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; +import BN from 'bn.js'; import { Solana } from '../../../chains/solana/solana'; -import { - CollectFeesRequest, - CollectFeesResponse, - CollectFeesRequestType, - CollectFeesResponseType, -} from '../../../schemas/clmm-schema'; +import { CollectFeesResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; -import { removeLiquidity } from './removeLiquidity'; - +/** + * Collect accumulated fees from a position WITHOUT touching its liquidity. + * + * The Raydium CLMM program has no owner-facing "collect fees" instruction — fees owed + * to a position are transferred by decrease_liquidity. Calling it with liquidity = 0 + * collects what is owed and leaves the position intact. This route used to remove 1% + * of the position and report the withdrawn principal as fees, which both mutated the + * position and mis-stated the amounts. + */ export async function collectFees( network: string, walletAddress: string, @@ -23,8 +26,7 @@ export async function collectFees( const raydium = await Raydium.getInstance(network); // Set the SDK owner to the wallet's public key — works for every wallet type (local, - // hardware). The actual liquidity removal (and signing/sending) is delegated to - // removeLiquidity below. + // hardware). The tx is built unsigned; signing/sending is delegated below. await raydium.setOwner(new PublicKey(walletAddress)); const position = await raydium.getClmmPosition(positionAddress); @@ -32,94 +34,70 @@ export async function collectFees( throw httpErrors.notFound(`Position not found: ${positionAddress}`); } - const [poolInfo] = await raydium.getClmmPoolfromAPI(position.poolId.toBase58()); + const [poolInfo, poolKeys] = await raydium.getClmmPoolfromAPI(position.poolId.toBase58()); const tokenA = await solana.getToken(poolInfo.mintA.address); const tokenB = await solana.getToken(poolInfo.mintB.address); - logger.info(`Collecting fees from CLMM position ${positionAddress} by removing 1% liquidity`); + logger.info(`Collecting fees from CLMM position ${positionAddress} via zero-liquidity decrease`); + + const COMPUTE_UNITS = 600000; + const priorityFeeInLamports = await solana.estimateGasPrice(); + const priorityFeePerCU = Math.floor(priorityFeeInLamports * 1e6); + + const { transaction } = await raydium.raydiumSDK.clmm.decreaseLiquidity({ + poolInfo, + poolKeys, + ownerPosition: position, + ownerInfo: { + useSOLBalance: true, + closePosition: false, + }, + liquidity: new BN(0), // collect fees only; principal untouched + amountMinA: new BN(0), + amountMinB: new BN(0), + txVersion: TxVersion.V0, + computeBudgetConfig: { + units: COMPUTE_UNITS, + microLamports: priorityFeePerCU, + }, + }); - // Remove 1% of liquidity to collect fees - const removeLiquidityResponse = await removeLiquidity( - network, - walletAddress, - positionAddress, - 1, // 1% of position - false, // don't close position - ); + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); - if (removeLiquidityResponse.status === 1 && removeLiquidityResponse.data) { - // Use the new helper to extract balance changes including fees + if (txData) { + // No liquidity moved, so the whole balance change is fees. const { baseTokenChange, quoteTokenChange } = await solana.extractClmmBalanceChanges( - removeLiquidityResponse.signature, + signature, walletAddress, tokenA, tokenB, - removeLiquidityResponse.data.fee * 1e9, ); - // The total balance change includes both liquidity removal and fee collection - // Since we know the liquidity amounts from removeLiquidity response, - // we can calculate the fee amounts - const baseFeeCollected = Math.abs(baseTokenChange) - removeLiquidityResponse.data.baseTokenAmountRemoved; - const quoteFeeCollected = Math.abs(quoteTokenChange) - removeLiquidityResponse.data.quoteTokenAmountRemoved; - logger.info( - `Fees collected from position ${positionAddress}: ${Math.max(0, baseFeeCollected).toFixed(4)} ${tokenA.symbol}, ${Math.max(0, quoteFeeCollected).toFixed(4)} ${tokenB.symbol}`, + `Fees collected from position ${positionAddress}: ${Math.abs(baseTokenChange).toFixed(4)} ${tokenA.symbol}, ${Math.abs(quoteTokenChange).toFixed(4)} ${tokenB.symbol}`, ); return { - signature: removeLiquidityResponse.signature, + signature, status: 1, // CONFIRMED data: { - fee: removeLiquidityResponse.data.fee, - baseFeeAmountCollected: Math.max(0, baseFeeCollected), - quoteFeeAmountCollected: Math.max(0, quoteFeeCollected), + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: position.poolId.toBase58(), + fee: txData.meta.fee / 1e9, + baseFeeAmountCollected: Math.abs(baseTokenChange), + quoteFeeAmountCollected: Math.abs(quoteTokenChange), }, }; - } else { - // Return pending status - return { - signature: removeLiquidityResponse.signature, - status: removeLiquidityResponse.status, - }; } -} - -export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - fastify.post<{ - Body: CollectFeesRequestType; - Reply: CollectFeesResponseType; - }>( - '/collect-fees', - { - schema: { - description: 'Collect fees from a Raydium CLMM position by removing 1% of liquidity', - tags: ['/connector/raydium'], - body: { - ...CollectFeesRequest, - properties: { - ...CollectFeesRequest.properties, - network: { type: 'string', default: 'mainnet-beta' }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - }, - }, - response: { 200: CollectFeesResponse }, - }, - }, - async (request) => { - try { - const { network, walletAddress, positionAddress } = request.body; - return await collectFees(network, walletAddress, positionAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to collect fees'); - } - }, - ); -}; - -export default collectFeesRoute; + return { + signature, + status: 0, // PENDING + }; +} diff --git a/src/connectors/raydium/clmm-routes/createPool.ts b/src/connectors/raydium/clmm-routes/createPool.ts index c6d4ed0344..5e1e6aca00 100644 --- a/src/connectors/raydium/clmm-routes/createPool.ts +++ b/src/connectors/raydium/clmm-routes/createPool.ts @@ -5,19 +5,16 @@ import { CLMM_PROGRAM_ID, DEVNET_PROGRAM_ID, } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getMint } from '@solana/spl-token'; import { Keypair, PublicKey } from '@solana/web3.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Raydium } from '../raydium'; -import { RaydiumClmmCreatePoolRequest } from '../schemas'; /** Resolves a token symbol or mint address to a PublicKey. */ async function resolveMint(solana: Solana, tokenOrAddress: string): Promise { @@ -62,11 +59,11 @@ function toApiV3Token(mint: PublicKey, decimals: number, programId: PublicKey): * route exists. */ async function fetchMarketPrice(network: string, baseToken: string, quoteToken: string): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { // Probe with 1 base token — we only need the price ratio, not a real trade size. - quote = await getUnifiedQuoteSwap(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); + quote = await getSwapQuote(`solana-${network}`, baseToken, quoteToken, 1, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + @@ -184,10 +181,9 @@ export async function createPool( walletAddress, (sdkSigners as Keypair[]) ?? [], ); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { return { @@ -197,43 +193,8 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - // Pool created + initialized only — no liquidity/position seeded. - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: - 'Create and initialize a new Raydium CLMM pool at an initial price. Does not open or seed a position.', - tags: ['/connector/raydium'], - body: RaydiumClmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, baseToken, quoteToken, initialPrice, ammConfigIndex } = request.body; - return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, ammConfigIndex); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/raydium/clmm-routes/executeSwap.ts b/src/connectors/raydium/clmm-routes/executeSwap.ts index beeb39a39b..0bd3e83027 100644 --- a/src/connectors/raydium/clmm-routes/executeSwap.ts +++ b/src/connectors/raydium/clmm-routes/executeSwap.ts @@ -1,15 +1,13 @@ import { ReturnTypeComputeAmountOutFormat, ReturnTypeComputeAmountOutBaseOut } from '@raydium-io/raydium-sdk-v2'; import { PublicKey, VersionedTransaction } from '@solana/web3.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { ExecuteSwapResponse, ExecuteSwapResponseType } from '../../../schemas/clmm-schema'; +import { ExecuteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumClmmExecuteSwapRequest, RaydiumClmmExecuteSwapRequestType } from '../schemas'; import { getSwapQuote, resolveCounterToken } from './quoteSwap'; @@ -154,20 +152,19 @@ export async function executeSwap( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); // Handle confirmation status const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, side, + slippagePct, ); if (result.status === 1) { @@ -178,80 +175,3 @@ export async function executeSwap( return result as ExecuteSwapResponseType; } - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: RaydiumClmmExecuteSwapRequestType; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on Raydium CLMM', - tags: ['/connector/raydium'], - body: RaydiumClmmExecuteSwapRequest, - response: { 200: ExecuteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, walletAddress, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = request.body; - const networkToUse = network; - - // If no pool address provided, find default pool - let poolAddressToUse = poolAddress; - if (!poolAddressToUse) { - const solana = await Solana.getInstance(networkToUse); - - // Resolve token symbols to get proper symbols for pool lookup - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest( - sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken), - ); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'raydium', - networkToUse, - 'clmm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Raydium`, - ); - } - - poolAddressToUse = pool.address; - } - - return await executeSwap( - networkToUse, - walletAddress, - poolAddressToUse, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - } catch (e) { - // Preserve the original error if it's a FastifyError - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to get swap quote'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/raydium/clmm-routes/index.ts b/src/connectors/raydium/clmm-routes/index.ts deleted file mode 100644 index d5b2724ac9..0000000000 --- a/src/connectors/raydium/clmm-routes/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { addLiquidityRoute } from './addLiquidity'; -import { closePositionRoute } from './closePosition'; -import { collectFeesRoute } from './collectFees'; -import { createPoolRoute } from './createPool'; -import { executeSwapRoute } from './executeSwap'; -import { openPositionRoute } from './openPosition'; -import { poolInfoRoute } from './poolInfo'; -import { positionInfoRoute } from './positionInfo'; -import { positionsOwnedRoute } from './positionsOwned'; -import { quotePositionRoute } from './quotePosition'; -import { quoteSwapRoute } from './quoteSwap'; -import { removeLiquidityRoute } from './removeLiquidity'; - -export const raydiumClmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionsOwnedRoute); - await fastify.register(positionInfoRoute); - await fastify.register(quotePositionRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(executeSwapRoute); - await fastify.register(openPositionRoute); - await fastify.register(createPoolRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(collectFeesRoute); - await fastify.register(closePositionRoute); -}; - -export default raydiumClmmRoutes; diff --git a/src/connectors/raydium/clmm-routes/openPosition.ts b/src/connectors/raydium/clmm-routes/openPosition.ts index 5f046e2903..1015ff6856 100644 --- a/src/connectors/raydium/clmm-routes/openPosition.ts +++ b/src/connectors/raydium/clmm-routes/openPosition.ts @@ -1,17 +1,15 @@ import { TxVersion, TickUtils } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { Keypair, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { OpenPositionResponse, OpenPositionResponseType } from '../../../schemas/clmm-schema'; +import { liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { OpenPositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumClmmOpenPositionRequest } from '../schemas'; import { quotePosition } from './quotePosition'; @@ -112,10 +110,9 @@ export async function openPosition( walletAddress, (sdkSigners as Keypair[]) ?? [], ); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; // Return with status @@ -124,12 +121,11 @@ export async function openPosition( const totalFee = txData.meta.fee; // Use the new helper method to extract balance changes - const { baseTokenChange, quoteTokenChange, rent } = await solana.extractClmmBalanceChanges( + const { baseTokenChange, quoteTokenChange, rent, accountSol } = await solana.extractClmmBalanceChanges( signature, walletAddress, baseTokenInfo, quoteTokenInfo, - totalFee, ); return { @@ -139,8 +135,16 @@ export async function openPosition( fee: totalFee / 1e9, positionAddress: extInfo.nftMint.toBase58(), positionRent: rent, - baseTokenAmountAdded: baseTokenChange, - quoteTokenAmountAdded: quoteTokenChange, + // Opening a position locks rent in the accounts it creates, and when one side is + // SOL that outflow sits inside its balance change — so the raw delta is both + // negative and larger than the deposit. The same helper the DAMM v2 open uses + // takes the magnitude and backs the accounts' lamports off the native side only. + baseTokenAmountAdded: liquidityWithoutRent(baseTokenChange, new PublicKey(baseTokenInfo.address), accountSol), + quoteTokenAmountAdded: liquidityWithoutRent( + quoteTokenChange, + new PublicKey(quoteTokenInfo.address), + accountSol, + ), }, }; } else { @@ -151,58 +155,3 @@ export async function openPosition( }; } } - -export const openPositionRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Solana.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: OpenPositionResponseType; - }>( - '/open-position', - { - schema: { - description: 'Open a new Raydium CLMM position', - tags: ['/connector/raydium'], - body: RaydiumClmmOpenPositionRequest, - response: { - 200: OpenPositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.body; - const networkToUse = network; - - return await openPosition( - networkToUse, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default openPositionRoute; diff --git a/src/connectors/raydium/clmm-routes/poolInfo.ts b/src/connectors/raydium/clmm-routes/poolInfo.ts index 72ce5d5f99..d385bf48bd 100644 --- a/src/connectors/raydium/clmm-routes/poolInfo.ts +++ b/src/connectors/raydium/clmm-routes/poolInfo.ts @@ -1,11 +1,10 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; +import { PoolInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { computeRaydiumBinDistribution } from '../raydium.utils'; -import { RaydiumClmmGetPoolInfoRequest, RaydiumClmmGetPoolInfoRequestType } from '../schemas'; export async function getPoolInfo( fastify: FastifyInstance, @@ -57,34 +56,3 @@ export async function getPoolInfo( return poolInfo; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: RaydiumClmmGetPoolInfoRequestType; - Reply: Record; - }>( - '/pool-info', - { - schema: { - description: 'Get CLMM pool information from Raydium', - tags: ['/connector/raydium'], - querystring: RaydiumClmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress, binCount = 0, network } = request.query; - return await getPoolInfo(fastify, network, poolAddress, binCount); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/raydium/clmm-routes/positionInfo.ts b/src/connectors/raydium/clmm-routes/positionInfo.ts index 8310c10caf..24735f6170 100644 --- a/src/connectors/raydium/clmm-routes/positionInfo.ts +++ b/src/connectors/raydium/clmm-routes/positionInfo.ts @@ -1,8 +1,7 @@ -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; -import { PositionInfo, PositionInfoSchema, GetPositionInfoRequestType } from '../../../schemas/clmm-schema'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { Raydium } from '../raydium'; -import { RaydiumClmmGetPositionInfoRequest } from '../schemas'; export async function getPositionInfo( fastify: FastifyInstance, @@ -23,33 +22,3 @@ export async function getPositionInfo( return positionInfo; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get info about a Raydium CLMM position', - tags: ['/connector/raydium'], - querystring: RaydiumClmmGetPositionInfoRequest, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { network = 'mainnet-beta', positionAddress } = request.query; - return await getPositionInfo(fastify, network, positionAddress); - } catch (e) { - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Failed to fetch position info'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/raydium/clmm-routes/positionsOwned.ts b/src/connectors/raydium/clmm-routes/positionsOwned.ts index fb683007d1..357b986b85 100644 --- a/src/connectors/raydium/clmm-routes/positionsOwned.ts +++ b/src/connectors/raydium/clmm-routes/positionsOwned.ts @@ -1,19 +1,12 @@ -import { Type, Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { PositionInfoSchema, PositionInfo } from '../../../schemas/clmm-schema'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; -import { RaydiumClmmGetPositionsOwnedRequest, RaydiumClmmGetPositionsOwnedRequestType } from '../schemas'; // Using Fastify's native error handling -const INVALID_SOLANA_ADDRESS_MESSAGE = (address: string) => `Invalid Solana address: ${address}`; - -const GetPositionsOwnedResponse = Type.Array(PositionInfoSchema); - -type GetPositionsOwnedResponseType = Static; export async function getPositionsOwned( fastify: FastifyInstance, @@ -84,38 +77,3 @@ async function fetchPositionsFromRPC(_solana: Solana, network: string, walletAdd logger.info(`Found ${allPositions.length} Raydium position(s) for wallet ${walletAddress.slice(0, 8)}...`); return allPositions; } - -export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { - // Remove wallet address example population code - - fastify.get<{ - Querystring: RaydiumClmmGetPositionsOwnedRequestType; - Reply: GetPositionsOwnedResponseType; - }>( - '/positions-owned', - { - schema: { - description: "Retrieve all positions owned by a user's wallet across all Raydium CLMM pools", - tags: ['/connector/raydium'], - querystring: RaydiumClmmGetPositionsOwnedRequest, - response: { - 200: GetPositionsOwnedResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress } = request.query; - return await getPositionsOwned(fastify, network, walletAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; // Re-throw HttpErrors with original message - } - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default positionsOwnedRoute; diff --git a/src/connectors/raydium/clmm-routes/quotePosition.ts b/src/connectors/raydium/clmm-routes/quotePosition.ts index 32268df249..90dab1dba1 100644 --- a/src/connectors/raydium/clmm-routes/quotePosition.ts +++ b/src/connectors/raydium/clmm-routes/quotePosition.ts @@ -1,16 +1,12 @@ import { TickUtils, PoolUtils } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import BN from 'bn.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { QuotePositionResponseType, QuotePositionResponse } from '../../../schemas/clmm-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { QuotePositionResponseType } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumClmmQuotePositionRequest } from '../schemas'; export async function quotePosition( network: string, @@ -44,12 +40,12 @@ export async function quotePosition( const rpcData = await raydium.getClmmPoolfromRPC(poolAddressToUse); poolInfo.price = rpcData.currentPrice; - const { tick: lowerTick, price: tickLowerPrice } = TickUtils.getPriceAndTick({ + const { tick: lowerTick } = TickUtils.getPriceAndTick({ poolInfo, price: new Decimal(lowerPrice), baseIn: true, }); - const { tick: upperTick, price: tickUpperPrice } = TickUtils.getPriceAndTick({ + const { tick: upperTick } = TickUtils.getPriceAndTick({ poolInfo, price: new Decimal(upperPrice), baseIn: true, @@ -157,53 +153,3 @@ export async function quotePosition( throw error; } } - -export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: Static; - Reply: QuotePositionResponseType; - }>( - '/quote-position', - { - schema: { - description: 'Quote amounts for a new Raydium CLMM position', - tags: ['/connector/raydium'], - querystring: RaydiumClmmQuotePositionRequest, - response: { - 200: QuotePositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network = 'mainnet-beta', - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.query; - - return await quotePosition( - network, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - undefined, // baseToken not needed anymore - undefined, // quoteToken not needed anymore - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to quote position'); - } - }, - ); -}; - -export default quotePositionRoute; diff --git a/src/connectors/raydium/clmm-routes/quoteSwap.ts b/src/connectors/raydium/clmm-routes/quoteSwap.ts index a41c2d0b72..c4390defbf 100644 --- a/src/connectors/raydium/clmm-routes/quoteSwap.ts +++ b/src/connectors/raydium/clmm-routes/quoteSwap.ts @@ -6,22 +6,14 @@ import { } from '@raydium-io/raydium-sdk-v2'; import { PublicKey } from '@solana/web3.js'; import { Decimal } from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; -import { estimateGasSolana } from '../../../chains/solana/routes/estimate-gas'; import { Solana } from '../../../chains/solana/solana'; -import { - QuoteSwapResponseType, - QuoteSwapResponse, - QuoteSwapRequestType, - QuoteSwapRequest, -} from '../../../schemas/clmm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Raydium } from '../raydium'; import { RaydiumConfig } from '../raydium.config'; -import { RaydiumClmmQuoteSwapRequest } from '../schemas'; export async function getSwapQuote( network: string, @@ -252,103 +244,6 @@ async function formatSwapQuote( } } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Raydium CLMM', - tags: ['/connector/raydium'], - querystring: RaydiumClmmQuoteSwapRequest, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = - request.query as typeof RaydiumClmmQuoteSwapRequest._type; - const networkToUse = network; - - // Validate essential parameters - if (!baseToken || !quoteToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, quoteToken, amount, and side are required'); - } - - const solana = await Solana.getInstance(networkToUse); - - let poolAddressToUse = poolAddress; - - // If poolAddress is not provided, look it up by token pair - if (!poolAddressToUse) { - // Resolve token symbols to get proper symbols for pool lookup - const baseTokenInfo = await solana.getToken(baseToken); - const quoteTokenInfo = await solana.getToken(quoteToken); - - if (!baseTokenInfo || !quoteTokenInfo) { - throw httpErrors.badRequest( - sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken), - ); - } - - // Use PoolService to find pool by token pair - const { PoolService } = await import('../../../services/pool-service'); - const poolService = PoolService.getInstance(); - - const pool = await poolService.getPool( - 'raydium', - networkToUse, - 'clmm', - baseTokenInfo.symbol, - quoteTokenInfo.symbol, - ); - - if (!pool) { - throw httpErrors.notFound( - `No CLMM pool found for ${baseTokenInfo.symbol}-${quoteTokenInfo.symbol} on Raydium`, - ); - } - - poolAddressToUse = pool.address; - } - - const result = await formatSwapQuote( - networkToUse, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - poolAddressToUse, - slippagePct, - ); - - let gasEstimation = null; - try { - gasEstimation = await estimateGasSolana(networkToUse); - } catch (error) { - logger.warn(`Failed to estimate gas for swap quote: ${error.message}`); - } - - return { - poolAddress: poolAddressToUse, - ...result, - }; - } catch (e) { - logger.error(e); - // Preserve the original error if it's a FastifyError - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to get swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Resolves the counter ("quote") token for a Raydium CLMM pool given the base token. The * standardized swap wrappers take poolAddress + baseToken and derive the other side from the pool diff --git a/src/connectors/raydium/clmm-routes/removeLiquidity.ts b/src/connectors/raydium/clmm-routes/removeLiquidity.ts index 668b1c09ba..f3bed047a1 100644 --- a/src/connectors/raydium/clmm-routes/removeLiquidity.ts +++ b/src/connectors/raydium/clmm-routes/removeLiquidity.ts @@ -1,20 +1,13 @@ import { TxVersion } from '@raydium-io/raydium-sdk-v2'; -import { Static } from '@sinclair/typebox'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import Decimal from 'decimal.js'; -import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { - RemoveLiquidityResponse, - RemoveLiquidityRequestType, - RemoveLiquidityResponseType, -} from '../../../schemas/clmm-schema'; -import { httpErrors } from '../../../services/error-handler'; +import { accountLifecycleSol, liquidityWithoutRent } from '../../../chains/solana/solana.utils'; +import { RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; -import { RaydiumClmmRemoveLiquidityRequest } from '../schemas'; export async function removeLiquidity( network: string, @@ -76,10 +69,9 @@ export async function removeLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; // Return with status @@ -93,11 +85,23 @@ export async function removeLiquidity( tokenBInfo?.address || poolInfo.mintB.address, ]); - const baseTokenBalanceChange = balanceChanges[0]; - const quoteTokenBalanceChange = balanceChanges[1]; + // A 100% removal closes the position and its NFT account in the same transaction, so + // their rent comes back inside the native side of this change. It is not liquidity. + // A partial removal closes nothing and this is a no-op. + const { closed } = accountLifecycleSol(txData); + const baseTokenBalanceChange = liquidityWithoutRent( + balanceChanges[0], + new PublicKey(tokenAInfo?.address || poolInfo.mintA.address), + closed, + ); + const quoteTokenBalanceChange = liquidityWithoutRent( + balanceChanges[1], + new PublicKey(tokenBInfo?.address || poolInfo.mintB.address), + closed, + ); logger.info( - `Liquidity removed from position ${positionAddress}: ${Math.abs(baseTokenBalanceChange).toFixed(4)} ${poolInfo.mintA.symbol}, ${Math.abs(quoteTokenBalanceChange).toFixed(4)} ${poolInfo.mintB.symbol}`, + `Liquidity removed from position ${positionAddress}: ${baseTokenBalanceChange.toFixed(4)} ${poolInfo.mintA.symbol}, ${quoteTokenBalanceChange.toFixed(4)} ${poolInfo.mintB.symbol}`, ); const totalFee = txData.meta.fee; @@ -105,9 +109,13 @@ export async function removeLiquidity( signature, status: 1, // CONFIRMED data: { + // The pool this position belongs to, already loaded here. The unified route is + // position-addressed and never receives it, so this is the only place it can + // come from without a second lookup. + poolAddress: positionInfo.poolId.toBase58(), fee: totalFee / 1e9, - baseTokenAmountRemoved: Math.abs(baseTokenBalanceChange), - quoteTokenAmountRemoved: Math.abs(quoteTokenBalanceChange), + baseTokenAmountRemoved: baseTokenBalanceChange, + quoteTokenAmountRemoved: quoteTokenBalanceChange, }, }; } else { @@ -118,35 +126,3 @@ export async function removeLiquidity( }; } } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from Raydium CLMM position', - tags: ['/connector/raydium'], - body: RaydiumClmmRemoveLiquidityRequest, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress, positionAddress, percentageToRemove } = request.body; - - return await removeLiquidity(network, walletAddress, positionAddress, percentageToRemove, false); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - throw fastify.httpErrors.internalServerError('Internal server error'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/raydium/raydium.routes.ts b/src/connectors/raydium/raydium.routes.ts deleted file mode 100644 index 9c6521d9e3..0000000000 --- a/src/connectors/raydium/raydium.routes.ts +++ /dev/null @@ -1,42 +0,0 @@ -import sensible from '@fastify/sensible'; -import type { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { raydiumAmmRoutes } from './amm-routes'; -import { raydiumClmmRoutes } from './clmm-routes'; - -// CLMM routes including swap endpoints -const raydiumClmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/raydium']; - } - }); - - await instance.register(raydiumClmmRoutes); - }); -}; - -// AMM routes including swap endpoints -const raydiumAmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/raydium']; - } - }); - - await instance.register(raydiumAmmRoutes); - }); -}; - -// Main export that combines all routes -export const raydiumRoutes = { - clmm: raydiumClmmRoutesWrapper, - amm: raydiumAmmRoutesWrapper, -}; diff --git a/src/connectors/raydium/raydium.ts b/src/connectors/raydium/raydium.ts index cbf5d2ac44..dcae4caff3 100644 --- a/src/connectors/raydium/raydium.ts +++ b/src/connectors/raydium/raydium.ts @@ -220,7 +220,7 @@ export class Raydium { } const poolIdString = position.poolId.toBase58(); - const [poolInfo, poolKeys] = await this.getClmmPoolfromAPI(poolIdString); + const [poolInfo] = await this.getClmmPoolfromAPI(poolIdString); const epochInfo = await this.solana.connection.getEpochInfo(); @@ -323,7 +323,12 @@ export class Raydium { address: poolAddress, baseTokenAddress: rawPool[poolAddress].baseMint.toString(), quoteTokenAddress: rawPool[poolAddress].quoteMint.toString(), - feePct: Number(rawPool[poolAddress].tradeFeeNumerator) / Number(rawPool[poolAddress].tradeFeeDenominator), + // feePct is a PERCENT on every other surface (getClmmPoolInfo above, Meteora, + // Orca), so the numerator/denominator ratio — a fraction — is scaled to match. + // Unscaled this reported 0.0025 for a pool charging 0.25%, and consumers render + // the field literally. + feePct: + (Number(rawPool[poolAddress].tradeFeeNumerator) / Number(rawPool[poolAddress].tradeFeeDenominator)) * 100, price: Number(rawPool[poolAddress].poolPrice), baseTokenAmount: Number(rawPool[poolAddress].mintAAmount) / 10 ** Number(rawPool[poolAddress].baseDecimal), quoteTokenAmount: Number(rawPool[poolAddress].mintBAmount) / 10 ** Number(rawPool[poolAddress].quoteDecimal), @@ -337,7 +342,9 @@ export class Raydium { address: poolAddress, baseTokenAddress: rawPool[poolAddress].mintA.toString(), quoteTokenAddress: rawPool[poolAddress].mintB.toString(), - feePct: Number(rawPool[poolAddress].configInfo?.tradeFeeRate || 0), + // CPMM's tradeFeeRate is in millionths (2500 = 0.25%); /10000 yields the percent, + // the same conversion getClmmPoolInfo applies to the CLMM config's rate. + feePct: Number(rawPool[poolAddress].configInfo?.tradeFeeRate || 0) / 10000, price: Number(rawPool[poolAddress].poolPrice), baseTokenAmount: Number(rawPool[poolAddress].baseReserve) / 10 ** Number(rawPool[poolAddress].mintDecimalA), quoteTokenAmount: Number(rawPool[poolAddress].quoteReserve) / 10 ** Number(rawPool[poolAddress].mintDecimalB), diff --git a/src/connectors/raydium/schemas.ts b/src/connectors/raydium/schemas.ts deleted file mode 100644 index 60745782b2..0000000000 --- a/src/connectors/raydium/schemas.ts +++ /dev/null @@ -1,689 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; - -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -import { RaydiumConfig } from './raydium.config'; - -// Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); - -// Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.01; -const BASE_TOKEN_AMOUNT = 0.01; -const QUOTE_TOKEN_AMOUNT = 2; -const LOWER_PRICE_BOUND = 100; -const UPPER_PRICE_BOUND = 300; -const AMM_POOL_ADDRESS_EXAMPLE = '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2'; -const CLMM_POOL_ADDRESS_EXAMPLE = '3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv'; - -// ======================================== -// AMM Request Schemas -// ======================================== - -export const RaydiumAmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Raydium AMM pool address', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), -}); - -export const RaydiumAmmGetPositionInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Raydium AMM pool address', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), -}); - -export const RaydiumAmmQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'AMM pool address (optional - can be looked up from baseToken and quoteToken)', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'The other token in the pair (optional - required if poolAddress not provided)', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -// Export the type for ExecuteSwapRequest -export type RaydiumClmmExecuteSwapRequestType = Static; - -export const RaydiumAmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'AMM pool address (optional - can be looked up from baseToken and quoteToken)', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -export const RaydiumAmmQuoteLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Raydium AMM pool address', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - examples: [QUOTE_TOKEN_AMOUNT], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -export const RaydiumAmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Raydium AMM pool address', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - examples: [QUOTE_TOKEN_AMOUNT], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -export const RaydiumAmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Raydium AMM pool address', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - examples: [100], - }), -}); - -export const RaydiumAmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will create and seed the pool', - default: solanaChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to seed the pool with', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: - 'Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. ' + - 'If omitted (and no initialPrice), the current market price is fetched from the swap router.', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. Overrides quoteTokenAmount. ' + - 'If both are omitted, the pool is seeded at the current market price so it is not immediately arbitraged.', - }), - ), - feeConfigIndex: Type.Optional( - Type.Integer({ - description: - 'Index into the CPMM fee-config list returned by the Raydium API (getCpmmConfigs). ' + - 'Default 0 selects the first/lowest fee tier.', - default: 0, - minimum: 0, - }), - ), - openTime: Type.Optional( - Type.Integer({ - description: 'Unix timestamp (seconds) when trading opens. Default 0 opens the pool immediately on confirmation.', - default: 0, - minimum: 0, - }), - ), -}); - -// ======================================== -// CLMM Request Schemas -// ======================================== - -export const RaydiumClmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Raydium CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - binCount: Type.Optional( - Type.Integer({ - description: - 'If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick), ' + - 'mirroring Meteora pool-info.bins[]. Default 0 — pool-info skips the extra tick-array fetch.', - default: 0, - minimum: 0, - maximum: 401, - }), - ), -}); -export type RaydiumClmmGetPoolInfoRequestType = Static; - -export const RaydiumClmmGetPositionInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), -}); - -export const RaydiumClmmQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'CLMM pool address (optional - can be looked up from tokens)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'The other token in the pair', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -export const RaydiumClmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - examples: [solanaChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'CLMM pool address (optional)', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: 'Trade direction', - enum: ['BUY', 'SELL'], - default: 'SELL', - examples: ['SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -// ======================================== -// CLMM Liquidity Request Schemas -// ======================================== - -export const RaydiumClmmOpenPositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Raydium CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -export const RaydiumClmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will create and initialize the pool', - default: solanaChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. If omitted, the current market price is fetched from the ' + - 'unified swap router so the pool opens on-market. No position is opened; only the pool is created.', - examples: [QUOTE_TOKEN_AMOUNT / BASE_TOKEN_AMOUNT], - }), - ), - ammConfigIndex: Type.Optional( - Type.Integer({ - description: - 'Index into the CLMM amm-config list returned by the Raydium API (getClmmConfigs). ' + - 'Each config carries a fee tier and tickSpacing. Default 0 selects the first/lowest tier.', - default: 0, - minimum: 0, - }), - ), -}); - -export const RaydiumClmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address', - examples: [''], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - examples: [BASE_TOKEN_AMOUNT], - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - examples: [QUOTE_TOKEN_AMOUNT], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); - -export const RaydiumClmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address to remove liquidity from', - examples: ['DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'], - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - examples: [100], - }), -}); - -export const RaydiumClmmClosePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address', - default: solanaChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'Position NFT address to close', - examples: ['DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'], - }), -}); - -export const RaydiumClmmGetPositionsOwnedRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - walletAddress: Type.String({ - description: 'Solana wallet address to check for positions', - examples: [solanaChainConfig.defaultWallet], - }), -}); - -export type RaydiumClmmGetPositionsOwnedRequestType = Static; - -export const RaydiumClmmQuotePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...RaydiumConfig.networks], - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Raydium CLMM pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: RaydiumConfig.config.slippagePct, - examples: [RaydiumConfig.config.slippagePct], - }), - ), -}); diff --git a/src/connectors/router-utils.ts b/src/connectors/router-utils.ts index 63b6f15ad1..08828ed18d 100644 --- a/src/connectors/router-utils.ts +++ b/src/connectors/router-utils.ts @@ -50,6 +50,58 @@ export interface ApproximateBuyResult { forwardQuote: ExactInQuote; } +/** + * A router's price impact as the percentage the schema documents. + * + * `QuoteSwapResponse.priceImpactPct` promises "Estimated price impact percentage + * (0-100)". Jupiter's field of the same name is a decimal *fraction* — 0.0126 means + * 1.26% — and it was passed through unconverted, so the number was 100x low against its + * own documentation, in the direction that makes a bad trade look harmless. A guard of + * the form `if (priceImpactPct > 5) reject` could never fire. + * + * Measured on SOL-USDC: a 20,000 SOL sell reported 0.001260 against a 0.134% impact + * computed from the quoted prices — agreement to within the fee once the trade is large + * enough for impact to dominate. + * + * Applies to the routers that serve Jupiter's quote schema: jupiter itself and dflow, + * whose quote response is that schema field for field. It does NOT apply to a router's + * native payload passed back for execution — that has to stay in the router's own units. + */ +export function priceImpactPercentFromFraction(fraction: string | number | undefined | null): number { + const parsed = parseFloat(String(fraction ?? '')); + // A router that omits the field reports 0, which is what the call sites' `|| '0'` did + // before this existed. It is indistinguishable from a measured zero — the field cannot + // express "not computed" — which is the half of this defect the schema still owes. + return Number.isFinite(parsed) ? parsed * 100 : 0; +} + +/** + * The route a quote actually attempted, for an error message a caller can act on. + * + * A SELL is ExactIn base -> quote; a BUY is ExactOut quote -> base. Every router here + * built its no-route message from the SELL shape and reused it for both, so a BUY that + * failed was reported as a failed ExactIn in the opposite direction — naming a route + * nobody tried. That matters because the message is a NO_ROUTE_FOUND, which reads as + * "this token is untradable": the same file already carries a fix for mislabelling a + * failure that way, after callers blacklisted good pools over it. A BUY declining + * approximation is precisely the case where ExactIn *does* route, since ExactIn is what + * the approximation would have used. + * + * `mode` overrides the side's default for a router whose executable mode differs from the + * one implied by the side, or to describe a compound attempt. + */ +export function attemptedRoute( + side: 'BUY' | 'SELL', + baseTokenName: string, + quoteTokenName: string, + mode?: string, +): string { + const buying = side === 'BUY'; + const from = buying ? quoteTokenName : baseTokenName; + const to = buying ? baseTokenName : quoteTokenName; + return `${from} -> ${to} (${mode ?? (buying ? 'ExactOut' : 'ExactIn')})`; +} + /** * Approximates a BUY (ExactOut) on a router that only supports ExactIn quotes. * diff --git a/src/connectors/titan/router-routes/executeQuote.ts b/src/connectors/titan/router-routes/executeQuote.ts index 26407e0eb2..79cfc4ceee 100644 --- a/src/connectors/titan/router-routes/executeQuote.ts +++ b/src/connectors/titan/router-routes/executeQuote.ts @@ -1,11 +1,8 @@ -import { FastifyPluginAsync } from 'fastify'; - import { Solana } from '../../../chains/solana/solana'; -import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; -import { TitanExecuteQuoteRequest } from '../schemas'; import { buildVersionedTransactionFromInstructions } from '../titan.utils'; export async function executeQuote( @@ -19,7 +16,7 @@ export async function executeQuote( throw httpErrors.badRequest('Quote not found or expired'); } - const { wallet, inputToken, outputToken, swapRoute } = cached; + const { wallet, inputToken, outputToken, swapRoute, slippagePct } = cached; // Titan instructions are built for a specific wallet; executing them from another wallet // would fail on-chain or move the wrong accounts — require a re-quote instead @@ -43,18 +40,18 @@ export async function executeQuote( ); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + slippagePct, ); // Remove quote from cache only after successful execution (confirmed) @@ -67,33 +64,3 @@ export async function executeQuote( return result as SwapExecuteResponseType; } - -export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteQuoteRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-quote', - { - schema: { - description: 'Execute a previously fetched quote from Titan (DART)', - tags: ['/connector/titan'], - body: TitanExecuteQuoteRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, quoteId } = request.body as typeof TitanExecuteQuoteRequest._type; - - return await executeQuote(walletAddress, network, quoteId); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing Titan quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeQuoteRoute; diff --git a/src/connectors/titan/router-routes/executeSwap.ts b/src/connectors/titan/router-routes/executeSwap.ts index f3573592f6..95c4cbec7f 100644 --- a/src/connectors/titan/router-routes/executeSwap.ts +++ b/src/connectors/titan/router-routes/executeSwap.ts @@ -1,9 +1,4 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; -import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; -import { TitanExecuteSwapRequest } from '../schemas'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { TitanConfig } from '../titan.config'; import { executeQuote } from './executeQuote'; @@ -36,43 +31,3 @@ async function executeSwap( } export { executeSwap }; - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Quote and execute a token swap on Titan (DART) in one step', - tags: ['/connector/titan'], - body: TitanExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut } = - request.body as typeof TitanExecuteSwapRequest._type; - - return await executeSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - approximateIfNoExactOut, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing Titan swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/titan/router-routes/index.ts b/src/connectors/titan/router-routes/index.ts deleted file mode 100644 index d612bb08d8..0000000000 --- a/src/connectors/titan/router-routes/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import executeQuoteRoute from './executeQuote'; -import executeSwapRoute from './executeSwap'; -import quoteSwapRoute from './quoteSwap'; - -export const titanRouterRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(quoteSwapRoute); - await fastify.register(executeQuoteRoute); - await fastify.register(executeSwapRoute); -}; - -export default titanRouterRoutes; diff --git a/src/connectors/titan/router-routes/quoteSwap.ts b/src/connectors/titan/router-routes/quoteSwap.ts index 20a8bdf3e0..974d6f91d6 100644 --- a/src/connectors/titan/router-routes/quoteSwap.ts +++ b/src/connectors/titan/router-routes/quoteSwap.ts @@ -1,16 +1,14 @@ import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { Solana } from '../../../chains/solana/solana'; import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; -import { QuoteSwapRequestType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { sanitizeErrorMessage } from '../../../services/sanitize'; -import { approximateBuyViaSellLeg } from '../../router-utils'; -import { TitanQuoteSwapRequest, TitanQuoteSwapResponse } from '../schemas'; +import { approximateBuyViaSellLeg, attemptedRoute } from '../../router-utils'; +import { TitanQuoteSwapResponse } from '../schemas'; import { Titan, TitanSwapResponse } from '../titan'; import { TitanConfig } from '../titan.config'; @@ -28,7 +26,7 @@ export async function quoteSwap( const titan = await Titan.getInstance(network); // Titan DART quotes are wallet-bound; fall back to the configured default wallet when - // the caller does not specify one (the unified /trading/swap dispatcher omits it) + // the caller does not specify one (the unified /trading/router dispatcher omits it) const wallet = walletAddress || getSolanaChainConfig().defaultWallet; if (!wallet) { throw httpErrors.badRequest( @@ -60,7 +58,7 @@ export async function quoteSwap( swapRoute = await titan.getSwapRoute(inputToken.address, outputToken.address, amountRaw, wallet, slippageBps); } catch (error) { throw httpErrors.noRouteFound( - `No route found for ${baseTokenInfo.symbol} -> ${quoteTokenInfo.symbol} (ExactIn). ${error?.message || error}`, + `No route found for ${attemptedRoute(side, baseTokenInfo.symbol, quoteTokenInfo.symbol)}. ${error?.message || error}`, ); } } else { @@ -128,43 +126,3 @@ export async function quoteSwap( wallet, }; } - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: Static; - }>( - '/quote-swap', - { - schema: { - description: 'Get an executable swap quote from Titan (DART)', - tags: ['/connector/titan'], - querystring: TitanQuoteSwapRequest, - response: { 200: TitanQuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut, walletAddress } = - request.query as typeof TitanQuoteSwapRequest._type; - - return await quoteSwap( - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - approximateIfNoExactOut, - walletAddress, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error getting Titan quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/connectors/titan/schemas.ts b/src/connectors/titan/schemas.ts index abbf8a2a19..41cecf2aa5 100644 --- a/src/connectors/titan/schemas.ts +++ b/src/connectors/titan/schemas.ts @@ -1,68 +1,8 @@ import { Type } from '@sinclair/typebox'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -import { TitanConfig } from './titan.config'; - // Get chain config for defaults -const solanaChainConfig = getSolanaChainConfig(); // Constants for examples -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.1; - -// Titan-specific quote-swap request (superset of base QuoteSwapRequest). -// Titan DART quotes are wallet-bound (the API builds instructions for a wallet even when -// only quoting), so walletAddress is accepted here and defaults to the Solana default wallet. -export const TitanQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...TitanConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: TitanConfig.config.slippagePct, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders: Titan DART is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', - default: true, - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet the quote instructions are built for (Titan quotes are wallet-bound)', - default: solanaChainConfig.defaultWallet, - }), - ), -}); // Titan-specific quote-swap response (superset of base QuoteSwapResponse) export const TitanQuoteSwapResponse = Type.Object({ @@ -104,74 +44,3 @@ export const TitanQuoteSwapResponse = Type.Object({ 'Wallet address this quote is bound to; execute-quote must be called with the same wallet or it will fail', }), }); - -// Titan-specific execute-quote request (superset of base ExecuteQuoteRequest) -export const TitanExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap (must match the wallet the quote was created for)', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...TitanConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the Titan quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), -}); - -// Titan-specific execute-swap request (superset of base ExecuteSwapRequest) -export const TitanExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Solana wallet address that will execute the swap', - default: solanaChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'Solana network to use', - default: solanaChainConfig.defaultNetwork, - enum: [...TitanConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Solana token symbol or address to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other Solana token symbol or address in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: TitanConfig.config.slippagePct, - }), - ), - approximateIfNoExactOut: Type.Optional( - Type.Boolean({ - description: - 'For BUY orders: Titan DART is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', - default: true, - }), - ), -}); diff --git a/src/connectors/titan/titan.routes.ts b/src/connectors/titan/titan.routes.ts deleted file mode 100644 index 818181dcdc..0000000000 --- a/src/connectors/titan/titan.routes.ts +++ /dev/null @@ -1,26 +0,0 @@ -import sensible from '@fastify/sensible'; -import type { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { titanRouterRoutes } from './router-routes'; - -// Titan routes with 3 endpoints -const titanRouterRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - // Decorate the instance with a hook to modify route options - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/titan']; - } - }); - - await instance.register(titanRouterRoutes); - }); -}; - -// Export routes in the same pattern as Jupiter -export const titanRoutes = { - router: titanRouterRoutesWrapper, -}; diff --git a/src/connectors/uniswap/amm-routes/addLiquidity.ts b/src/connectors/uniswap/amm-routes/addLiquidity.ts index 62b46cdfa8..5944c02a01 100644 --- a/src/connectors/uniswap/amm-routes/addLiquidity.ts +++ b/src/connectors/uniswap/amm-routes/addLiquidity.ts @@ -1,16 +1,13 @@ import { Contract } from '@ethersproject/contracts'; -import { Static } from '@sinclair/typebox'; import { Percent } from '@uniswap/sdk-core'; -import { BigNumber, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; -import { re } from 'mathjs'; +import { BigNumber } from 'ethers'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { wrapEthereum } from '../../../chains/ethereum/routes/wrap'; -import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/amm-schema'; +import { AddLiquidityResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { UniswapAmmAddLiquidityRequest } from '../schemas'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; import { IUniswapV2Router02ABI } from '../uniswap.contracts'; @@ -31,8 +28,6 @@ async function addLiquidityInternal( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = UniswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const networkToUse = network; @@ -40,6 +35,11 @@ async function addLiquidityInternal( let actualBaseToken = baseToken; let baseWrapTxHash = null; if (baseToken === 'ETH') { + // Declared here, as the quote-token branch below does. It used to resolve to a + // function-scope binding declared further down, which made this branch a + // guaranteed ReferenceError: adding liquidity with ETH as the base token could + // never have worked. The dead-code sweep that removed the unused later binding is + // what surfaced it. const uniswap = await Uniswap.getInstance(networkToUse); const wethToken = await uniswap.getToken('WETH'); if (!wethToken) { @@ -87,7 +87,6 @@ async function addLiquidityInternal( // Get Ethereum instance const ethereum = await Ethereum.getInstance(networkToUse); - const uniswap = await Uniswap.getInstance(networkToUse); // Get wallet const wallet = await ethereum.getWallet(walletAddress); @@ -182,8 +181,7 @@ async function addLiquidityInternal( // Add liquidity Token + ETH // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); gasOptions.value = quote.rawQuoteTokenAmount; tx = await router.addLiquidityETH( @@ -244,8 +242,7 @@ async function addLiquidityInternal( // Add liquidity Token + Token // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); tx = await router.addLiquidity( quote.baseTokenObj.address, @@ -261,19 +258,17 @@ async function addLiquidityInternal( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the quoted amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountAdded: quote.baseTokenAmount, quoteTokenAmountAdded: quote.quoteTokenAmount, ...(baseWrapTxHash && { baseWrapTxHash }), @@ -293,8 +288,6 @@ export async function addLiquidity( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = UniswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const poolInfo = await getUniswapPoolInfo(poolAddress, network, 'amm'); if (!poolInfo) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); @@ -308,93 +301,5 @@ export async function addLiquidity( baseTokenAmount, quoteTokenAmount, slippagePct, - gasPrice, - maxGas, ); } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to a Uniswap V2 pool', - tags: ['/connector/uniswap'], - body: UniswapAmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - walletAddress: requestedWalletAddress, - gasPrice, - maxGas, - } = request.body; - - // Validate essential parameters - if (!poolAddress || !baseTokenAmount || !quoteTokenAmount) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - const networkToUse = network; - - // Get wallet address - either from request or first available - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - return await addLiquidity( - networkToUse, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - gasPrice, - maxGas, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - - // Handle specific user-actionable errors - if (e.message && e.message.includes('Insufficient allowance')) { - logger.error('Request error:', e); - throw fastify.httpErrors.badRequest('Invalid request'); - } - - // Handle insufficient funds errors - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', - ); - } - - throw fastify.httpErrors.internalServerError('Failed to add liquidity'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/uniswap/amm-routes/createPool.ts b/src/connectors/uniswap/amm-routes/createPool.ts index 945078d9b6..6e7419dcd0 100644 --- a/src/connectors/uniswap/amm-routes/createPool.ts +++ b/src/connectors/uniswap/amm-routes/createPool.ts @@ -1,15 +1,13 @@ import { Contract } from '@ethersproject/contracts'; -import { Static } from '@sinclair/typebox'; import { Percent } from '@uniswap/sdk-core'; import { Decimal } from 'decimal.js'; import { BigNumber, constants, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { UniswapAmmCreatePoolRequest } from '../schemas'; import { UniswapConfig } from '../uniswap.config'; import { IUniswapV2FactoryABI, @@ -54,10 +52,10 @@ async function fetchMarketPrice( quoteToken: string, amount: number, ): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + quote = await getSwapQuote(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + @@ -80,8 +78,6 @@ export async function createPool( baseTokenAmount: number, quoteTokenAmount?: number, initialPrice?: number, - gasPrice?: number, - maxGas?: number, slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { if (baseTokenAmount <= 0) { @@ -178,10 +174,6 @@ export async function createPool( const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - // gasPrice arrives already in gwei (the unit prepareGasOptions expects). The connector's Fastify - // route accepts gasPrice as a wei string (sibling shape) and converts it to gwei before calling. - const gasPriceGwei = gasPrice; - let tx; if (baseIsEth || quoteIsEth) { // One side is ETH/WETH → addLiquidityETH. The ERC20 side needs an allowance to the router; the @@ -204,7 +196,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); gasOptions.value = ethRawAmount; tx = await router.addLiquidityETH( @@ -248,7 +240,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); tx = await router.addLiquidity( baseTokenInfo.address, @@ -265,118 +257,32 @@ export async function createPool( logger.info(`Creating Uniswap V2 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pair address from the factory — authoritative source of the pool address. const pairAddress: string = await factory.getPair(baseTokenInfo.address, quoteTokenInfo.address); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can + // reconcile it. The seed amounts below have not moved, so they are deliberately omitted. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress: pairAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: baseTokenAmount, - quoteTokenAmountAdded: effectiveQuoteAmount, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress: pairAddress, price: seedPrice, + data: { + fee: outcome.fee, + baseTokenAmountAdded: baseTokenAmount, + quoteTokenAmountAdded: effectiveQuoteAmount, + }, }; } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: 'Create a new Uniswap V2 (AMM) pool and seed it with initial liquidity (fixed 0.30% fee)', - tags: ['/connector/uniswap'], - body: UniswapAmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - initialPrice, - slippagePct, - gasPrice, - maxGas, - walletAddress: requestedWalletAddress, - } = request.body; - - if (!baseToken || !quoteToken || !baseTokenAmount) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - // Route accepts gasPrice as a wei string (matching sibling AMM requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - - return await createPool( - network, - walletAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - initialPrice, - gasPriceGwei, - maxGas, - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - - if (e.message && e.message.includes('Insufficient allowance')) { - throw fastify.httpErrors.badRequest(e.message); - } - if (e.message && e.message.includes('already exists')) { - throw fastify.httpErrors.badRequest(e.message); - } - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', - ); - } - - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/uniswap/amm-routes/executeSwap.ts b/src/connectors/uniswap/amm-routes/executeSwap.ts index d9830dd32b..c9ebdd13d4 100644 --- a/src/connectors/uniswap/amm-routes/executeSwap.ts +++ b/src/connectors/uniswap/amm-routes/executeSwap.ts @@ -1,13 +1,11 @@ import { BigNumber, Contract, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; -import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { UniswapAmmExecuteSwapRequest } from '../schemas'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; import { getUniswapV2RouterAddress, IUniswapV2Router02ABI } from '../uniswap.contracts'; @@ -86,7 +84,7 @@ export async function executeAmmSwap( // Prepare transaction parameters const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -151,7 +149,7 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -208,19 +206,18 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction confirmed: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction confirmed: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -230,27 +227,22 @@ export async function executeAmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { @@ -278,51 +270,6 @@ export async function executeAmmSwap( } } -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on Uniswap V2 AMM using Router02', - tags: ['/connector/uniswap'], - body: UniswapAmmExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const ethereumConfig = getEthereumChainConfig(); - const { - walletAddress = ethereumConfig.defaultWallet, - network = ethereumConfig.defaultNetwork, - baseToken, - quoteToken, - amount, - side = 'SELL', - slippagePct, - } = request.body as typeof UniswapAmmExecuteSwapRequest._type; - - return await executeAmmSwap( - walletAddress, - network, - baseToken, - quoteToken || '', // Handle optional quoteToken - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - /** * Standard AMM execute-swap entry point (network-based) — consumed by the unified /trading/amm * dispatcher. The quote token is derived from the pool; `amount` is denominated in the base token. @@ -339,5 +286,3 @@ export async function executeSwap( const { baseAddress, quoteAddress } = await resolveSwapPair(network, poolAddress, baseToken); return await executeAmmSwap(walletAddress, network, baseAddress, quoteAddress, amount, side, slippagePct); } - -export default executeSwapRoute; diff --git a/src/connectors/uniswap/amm-routes/index.ts b/src/connectors/uniswap/amm-routes/index.ts deleted file mode 100644 index 651da9f512..0000000000 --- a/src/connectors/uniswap/amm-routes/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import addLiquidityRoute from './addLiquidity'; -import createPoolRoute from './createPool'; -import executeSwapRoute from './executeSwap'; -import poolInfoRoute from './poolInfo'; -import positionInfoRoute from './positionInfo'; -import quoteLiquidityRoute from './quoteLiquidity'; -import quoteSwapRoute from './quoteSwap'; -import removeLiquidityRoute from './removeLiquidity'; - -export const uniswapAmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionInfoRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(quoteLiquidityRoute); - await fastify.register(executeSwapRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(createPoolRoute); - await fastify.register(removeLiquidityRoute); -}; - -export default uniswapAmmRoutes; diff --git a/src/connectors/uniswap/amm-routes/poolInfo.ts b/src/connectors/uniswap/amm-routes/poolInfo.ts index 3e6d30dec2..89c612a4a2 100644 --- a/src/connectors/uniswap/amm-routes/poolInfo.ts +++ b/src/connectors/uniswap/amm-routes/poolInfo.ts @@ -1,11 +1,8 @@ import { Contract } from '@ethersproject/contracts'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/amm-schema'; +import { PoolInfo } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; -import { UniswapAmmGetPoolInfoRequest } from '../schemas'; import { Uniswap } from '../uniswap'; import { IUniswapV2PairABI } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; @@ -57,48 +54,3 @@ export async function getPoolInfo(network: string, poolAddress: string): Promise quoteTokenAmount, }; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: GetPoolInfoRequestType; - Reply: Record; - }>( - '/pool-info', - { - schema: { - description: 'Get AMM pool information from Uniswap V2', - tags: ['/connector/uniswap'], - querystring: UniswapAmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress, network } = request.query; - return await getPoolInfo(network, poolAddress); - } catch (e) { - logger.error(`Error in pool-info route: ${e.message}`); - if (e.stack) { - logger.debug(`Stack trace: ${e.stack}`); - } - - // Return appropriate error based on the error message - if (e.statusCode) { - throw e; // Already a formatted error carrying an HTTP status - } else if (e.message && e.message.includes('invalid address')) { - throw fastify.httpErrors.badRequest(`Invalid pool address`); - } else if (e.message && e.message.includes('not found')) { - logger.error('Not found error:', e); - throw fastify.httpErrors.notFound('Resource not found'); - } else { - logger.error('Unexpected error fetching pool info:', e); - throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); - } - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/uniswap/amm-routes/positionInfo.ts b/src/connectors/uniswap/amm-routes/positionInfo.ts index bebeca2b9a..9fb3e67d1d 100644 --- a/src/connectors/uniswap/amm-routes/positionInfo.ts +++ b/src/connectors/uniswap/amm-routes/positionInfo.ts @@ -1,16 +1,9 @@ import { Contract } from '@ethersproject/contracts'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - GetPositionInfoRequestType, - GetPositionInfoRequest, - PositionInfo, - PositionInfoSchema, -} from '../../../schemas/amm-schema'; +import { PositionInfo } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Uniswap } from '../uniswap'; import { IUniswapV2PairABI } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; @@ -97,145 +90,3 @@ export async function checkLPAllowance( ); } } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get position information for a Uniswap V2 pool', - tags: ['/connector/uniswap'], - querystring: { - ...GetPositionInfoRequest, - properties: { - network: { type: 'string', default: 'base' }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - poolAddress: { - type: 'string', - examples: [''], - }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - }, - }, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { network, poolAddress, walletAddress: requestedWalletAddress } = request.query; - - const networkToUse = network; - - // Validate essential parameters - if (!poolAddress) { - throw fastify.httpErrors.badRequest('Pool address is required'); - } - - // Get Uniswap and Ethereum instances - const uniswap = await Uniswap.getInstance(networkToUse); - const ethereum = await Ethereum.getInstance(networkToUse); - - // Get wallet address - either from request or first available - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await uniswap.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - // Get the pair contract - const pairContract = new Contract(poolAddress, IUniswapV2PairABI.abi, ethereum.provider); - - // Get LP token balance for the wallet - const lpBalance = await pairContract.balanceOf(walletAddress); - - // Get token addresses from the pair - const [token0, token1] = await Promise.all([pairContract.token0(), pairContract.token1()]); - - // Get token objects by address - const baseTokenObj = await uniswap.getToken(token0); - const quoteTokenObj = await uniswap.getToken(token1); - - if (!baseTokenObj || !quoteTokenObj) { - throw fastify.httpErrors.badRequest('Token information not found for pool'); - } - - // If no position, return early - if (lpBalance.isZero()) { - return { - poolAddress, - walletAddress, - baseTokenAddress: baseTokenObj.address, - quoteTokenAddress: quoteTokenObj.address, - lpTokenAmount: 0, - baseTokenAmount: 0, - quoteTokenAmount: 0, - price: 0, - }; - } - - // Get total supply and reserves - const [totalSupply, reserves] = await Promise.all([pairContract.totalSupply(), pairContract.getReserves()]); - - // Determine which token is base and which is quote - const token0IsBase = token0.toLowerCase() === baseTokenObj.address.toLowerCase(); - - // Calculate user's share of the pool - const userShare = lpBalance.mul(10000).div(totalSupply).toNumber() / 10000; // Convert to percentage - - // Calculate token amounts - const baseTokenReserve = token0IsBase ? reserves[0] : reserves[1]; - const quoteTokenReserve = token0IsBase ? reserves[1] : reserves[0]; - - const userBaseTokenAmount = baseTokenReserve.mul(lpBalance).div(totalSupply); - const userQuoteTokenAmount = quoteTokenReserve.mul(lpBalance).div(totalSupply); - - // Calculate price (quoteToken per baseToken) - const baseTokenAmountFloat = formatTokenAmount(baseTokenReserve.toString(), baseTokenObj.decimals); - const quoteTokenAmountFloat = formatTokenAmount(quoteTokenReserve.toString(), quoteTokenObj.decimals); - const price = quoteTokenAmountFloat / baseTokenAmountFloat; - - // Format for response - logger.info(`Raw LP balance: ${lpBalance.toString()}`); - logger.info(`Total supply: ${totalSupply.toString()}`); - - const formattedLpAmount = formatTokenAmount(lpBalance.toString(), 18); // LP tokens have 18 decimals - const formattedBaseAmount = formatTokenAmount(userBaseTokenAmount.toString(), baseTokenObj.decimals); - const formattedQuoteAmount = formatTokenAmount(userQuoteTokenAmount.toString(), quoteTokenObj.decimals); - - logger.info(`Formatted LP amount: ${formattedLpAmount}`); - logger.info(`Formatted base amount: ${formattedBaseAmount}`); - logger.info(`Formatted quote amount: ${formattedQuoteAmount}`); - - return { - poolAddress, - walletAddress, - baseTokenAddress: baseTokenObj.address, - quoteTokenAddress: quoteTokenObj.address, - lpTokenAmount: formattedLpAmount, - baseTokenAmount: formattedBaseAmount, - quoteTokenAmount: formattedQuoteAmount, - price, - }; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to get position info'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/uniswap/amm-routes/quoteLiquidity.ts b/src/connectors/uniswap/amm-routes/quoteLiquidity.ts index 4fe7454503..03bc2bf58a 100644 --- a/src/connectors/uniswap/amm-routes/quoteLiquidity.ts +++ b/src/connectors/uniswap/amm-routes/quoteLiquidity.ts @@ -1,18 +1,12 @@ import { Contract } from '@ethersproject/contracts'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - QuoteLiquidityRequestType, - QuoteLiquidityRequest, - QuoteLiquidityResponseType, - QuoteLiquidityResponse, -} from '../../../schemas/amm-schema'; +import { QuoteLiquidityResponseType } from '../../../schemas/amm-schema'; import { logger } from '../../../services/logger'; import { Uniswap } from '../uniswap'; import { IUniswapV2PairABI, getUniswapV2RouterAddress } from '../uniswap.contracts'; -import { formatTokenAmount, getUniswapPoolInfo } from '../uniswap.utils'; +import { formatTokenAmount } from '../uniswap.utils'; import { getAmmPoolTokens } from './poolTokens'; @@ -82,7 +76,7 @@ export async function getUniswapAmmLiquidityQuote( const pairContract = new Contract(poolAddressToUse, IUniswapV2PairABI.abi, ethereum.provider); // Get token addresses and reserves - const [token0, token1, reserves] = await Promise.all([ + const [token0, , reserves] = await Promise.all([ pairContract.token0(), pairContract.token1(), pairContract.getReserves(), @@ -175,87 +169,6 @@ export async function getUniswapAmmLiquidityQuote( }; } -export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - fastify.get<{ - Querystring: QuoteLiquidityRequestType; - Reply: QuoteLiquidityResponseType; - }>( - '/quote-liquidity', - { - schema: { - description: 'Get liquidity quote for a Uniswap V2 pool', - tags: ['/connector/uniswap'], - querystring: { - ...QuoteLiquidityRequest, - properties: { - ...QuoteLiquidityRequest.properties, - network: { type: 'string', default: 'base' }, - poolAddress: { - type: 'string', - examples: [''], - }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - baseTokenAmount: { type: 'number', examples: [0.001] }, - quoteTokenAmount: { type: 'number', examples: [2.5] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { - 200: QuoteLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; - - if (!poolAddress) { - throw fastify.httpErrors.badRequest('Pool address is required'); - } - - // Get pool information to determine tokens - const poolInfo = await getUniswapPoolInfo(poolAddress, network, 'amm'); - if (!poolInfo) { - throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); - } - - const baseToken = poolInfo.baseTokenAddress; - const quoteToken = poolInfo.quoteTokenAddress; - - const quote = await getUniswapAmmLiquidityQuote( - network, - poolAddress, - baseToken, - quoteToken, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - - // Use standard gas limit for liquidity operations - const computeUnits = 500000; - - return { - baseLimited: quote.baseLimited, - baseTokenAmount: quote.baseTokenAmount, - quoteTokenAmount: quote.quoteTokenAmount, - baseTokenAmountMax: quote.baseTokenAmountMax, - quoteTokenAmountMax: quote.quoteTokenAmountMax, - computeUnits, - }; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to get liquidity quote'); - } - }, - ); -}; - /** * Standard AMM quote-liquidity entry point (network-based) — consumed by the unified /trading/amm * dispatcher. Base/quote follow the pair's token0/token1 orientation. @@ -285,5 +198,3 @@ export async function quoteLiquidity( quoteTokenAmountMax: q.quoteTokenAmountMax, }; } - -export default quoteLiquidityRoute; diff --git a/src/connectors/uniswap/amm-routes/quoteSwap.ts b/src/connectors/uniswap/amm-routes/quoteSwap.ts index 7241f4f18b..88ba08fa0d 100644 --- a/src/connectors/uniswap/amm-routes/quoteSwap.ts +++ b/src/connectors/uniswap/amm-routes/quoteSwap.ts @@ -1,20 +1,14 @@ import { Token, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core'; -import { Pair as V2Pair, Route as V2Route, Trade as V2Trade } from '@uniswap/v2-sdk'; +import { Route as V2Route, Trade as V2Trade } from '@uniswap/v2-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - QuoteSwapRequestType, - QuoteSwapResponseType, - QuoteSwapRequest, - QuoteSwapResponse, -} from '../../../schemas/amm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/amm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; -import { formatTokenAmount, getUniswapPoolInfo } from '../uniswap.utils'; +import { formatTokenAmount } from '../uniswap.utils'; import { resolveSwapPair } from './poolTokens'; @@ -190,7 +184,7 @@ async function formatSwapQuote( try { // Use the extracted quote function - const { quote, uniswap, ethereum, baseTokenObj, quoteTokenObj } = await getUniswapAmmQuote( + const { quote, ethereum } = await getUniswapAmmQuote( network, poolAddress, baseToken, @@ -244,7 +238,6 @@ async function formatSwapQuote( const tokenOut = quote.outputToken.address; // Calculate fee (V2 has 0.3% fixed fee) - const fee = quote.estimatedAmountIn * 0.003; return { // Base QuoteSwapResponse fields in correct order @@ -269,144 +262,6 @@ async function formatSwapQuote( } } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - // Import the httpErrors plugin to ensure it's available - await fastify.register(require('@fastify/sensible')); - - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Uniswap V2 AMM', - tags: ['/connector/uniswap'], - querystring: { - ...QuoteSwapRequest, - properties: { - ...QuoteSwapRequest.properties, - network: { type: 'string', default: 'base' }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - amount: { type: 'number', examples: [0.001] }, - side: { type: 'string', enum: ['BUY', 'SELL'], examples: ['SELL'] }, - poolAddress: { type: 'string', examples: [''] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, poolAddress, baseToken, quoteToken, amount, side, slippagePct } = request.query; - - const networkToUse = network; - - // Validate essential parameters - if (!baseToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, amount, and side are required'); - } - - const uniswap = await Uniswap.getInstance(networkToUse); - - let poolAddressToUse = poolAddress; - let baseTokenToUse: string; - let quoteTokenToUse: string; - - if (poolAddressToUse) { - // Pool address provided, get pool info to determine tokens - const poolInfo = await getUniswapPoolInfo(poolAddressToUse, networkToUse, 'amm'); - if (!poolInfo) { - throw httpErrors.notFound(`Pool not found: ${poolAddressToUse}`); - } - - // Determine which token is base and which is quote based on the provided baseToken - if (baseToken === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (baseToken === poolInfo.quoteTokenAddress) { - // User specified the quote token as base, so swap them - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - // Try to resolve baseToken as symbol to address - const resolvedToken = await uniswap.getToken(baseToken); - - if (resolvedToken) { - if (resolvedToken.address === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (resolvedToken.address === poolInfo.quoteTokenAddress) { - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } - } else { - // No pool address provided, need quoteToken to find pool - if (!quoteToken) { - throw httpErrors.badRequest('quoteToken is required when poolAddress is not provided'); - } - - baseTokenToUse = baseToken; - quoteTokenToUse = quoteToken; - - // Find pool using findDefaultPool - poolAddressToUse = await uniswap.findDefaultPool(baseTokenToUse, quoteTokenToUse, 'amm'); - - if (!poolAddressToUse) { - throw httpErrors.notFound(`No AMM pool found for pair ${baseTokenToUse}-${quoteTokenToUse}`); - } - } - - return await formatSwapQuote( - networkToUse, - poolAddressToUse, - baseTokenToUse, - quoteTokenToUse, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - logger.error(`Error in quote-swap route: ${e.message}`); - - // If it's already a Fastify HTTP error, re-throw it - if (e.statusCode) { - throw e; - } - - // Check for specific error types - if (e.message?.includes('Insufficient liquidity')) { - logger.error('Request error:', e); - throw httpErrors.badRequest('Invalid request'); - } - if (e.message?.includes('Pool not found') || e.message?.includes('No AMM pool found')) { - logger.error('Pool not found error:', e); - throw httpErrors.notFound(e.message || 'Pool not found'); - } - if (e.message?.includes('token not found')) { - logger.error('Request error:', e); - throw httpErrors.badRequest('Invalid request'); - } - - // Default to internal server error - logger.error('Unexpected error getting swap quote:', e); - logger.error('Error stack:', e.stack); - throw httpErrors.internalServerError(e.message || 'Error getting swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Standard AMM quote-swap entry point (network-based) — consumed by the unified /trading/amm * dispatcher. `amount` is denominated in the base token; the quote token is derived from the pool. diff --git a/src/connectors/uniswap/amm-routes/removeLiquidity.ts b/src/connectors/uniswap/amm-routes/removeLiquidity.ts index cfe1f9d645..4c91378805 100644 --- a/src/connectors/uniswap/amm-routes/removeLiquidity.ts +++ b/src/connectors/uniswap/amm-routes/removeLiquidity.ts @@ -1,14 +1,10 @@ import { Contract } from '@ethersproject/contracts'; -import { Static } from '@sinclair/typebox'; import { Percent } from '@uniswap/sdk-core'; -import { utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; -import { UniswapAmmRemoveLiquidityRequest } from '../schemas'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; import { getUniswapV2RouterAddress, IUniswapV2Router02ABI, IUniswapV2PairABI } from '../uniswap.contracts'; @@ -29,8 +25,6 @@ export async function removeLiquidity( poolAddress: string, percentageToRemove: number, slippagePct: number = UniswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { if (!poolAddress || !percentageToRemove) throw httpErrors.badRequest('Missing required parameters'); if (percentageToRemove <= 0 || percentageToRemove > 100) { @@ -85,8 +79,7 @@ export async function removeLiquidity( await checkLPAllowance(ethereum, wallet, poolAddress, routerAddress, liquidityToRemove); const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_REMOVE_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_REMOVE_LIQUIDITY_GAS_LIMIT); let tx; if (baseTokenObj.symbol === 'WETH') { @@ -122,82 +115,22 @@ export async function removeLiquidity( ); } - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the expected amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } + const baseTokenAmountRemoved = formatTokenAmount(expectedBaseTokenAmount.toString(), baseTokenObj.decimals); const quoteTokenAmountRemoved = formatTokenAmount(expectedQuoteTokenAmount.toString(), quoteTokenObj.decimals); - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, }; } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a Uniswap V2 pool', - tags: ['/connector/uniswap'], - body: UniswapAmmRemoveLiquidityRequest, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - poolAddress, - percentageToRemove, - walletAddress: requestedWalletAddress, - gasPrice, - maxGas, - } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - return await removeLiquidity( - network, - walletAddress, - poolAddress, - percentageToRemove, - undefined, - gasPrice, - maxGas, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) throw e; - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', - ); - } - throw fastify.httpErrors.internalServerError('Failed to remove liquidity'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/uniswap/clmm-routes/addLiquidity.ts b/src/connectors/uniswap/clmm-routes/addLiquidity.ts index 9594bbcd46..5a00a208cd 100644 --- a/src/connectors/uniswap/clmm-routes/addLiquidity.ts +++ b/src/connectors/uniswap/clmm-routes/addLiquidity.ts @@ -1,19 +1,16 @@ import { Contract } from '@ethersproject/contracts'; -import { Static } from '@sinclair/typebox'; import { CurrencyAmount, Percent } from '@uniswap/sdk-core'; -import { Position, NonfungiblePositionManager } from '@uniswap/v3-sdk'; +import { Position, NonfungiblePositionManager, computePoolAddress } from '@uniswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { AddLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; -import { UniswapClmmAddLiquidityRequest } from '../schemas'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; -import { getUniswapV3NftManagerAddress, POSITION_MANAGER_ABI } from '../uniswap.contracts'; +import { getUniswapV3NftManagerAddress, POSITION_MANAGER_ABI, getUniswapV3FactoryAddress } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; // Default gas limit for CLMM add liquidity operations @@ -44,6 +41,16 @@ export async function addLiquidity( const token0 = await uniswap.getToken(position.token0); const token1 = await uniswap.getToken(position.token1); + + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + factoryAddress: getUniswapV3FactoryAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); const fee = position.fee; const tickLower = position.tickLower; const tickUpper = position.tickUpper; @@ -152,9 +159,12 @@ export async function addLiquidity( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_ADD_LIQUIDITY_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the mint amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const actualToken0Amount = formatTokenAmount(newPosition.mintAmounts.amount0.toString(), token0.decimals); const actualToken1Amount = formatTokenAmount(newPosition.mintAmounts.amount1.toString(), token1.decimals); @@ -162,69 +172,13 @@ export async function addLiquidity( const actualQuoteAmount = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, baseTokenAmountAdded: actualBaseAmount, quoteTokenAmountAdded: actualQuoteAmount, }, }; } - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to an existing Uniswap V3 position', - tags: ['/connector/uniswap'], - body: UniswapClmmAddLiquidityRequest, - response: { - 200: AddLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress: requestedWalletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const uniswap = await Uniswap.getInstance(network); - walletAddress = await uniswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await addLiquidity( - network, - walletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e: any) { - logger.error('Failed to add liquidity:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to add liquidity'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/connectors/uniswap/clmm-routes/closePosition.ts b/src/connectors/uniswap/clmm-routes/closePosition.ts index 972dabf7f1..f3111d489b 100644 --- a/src/connectors/uniswap/clmm-routes/closePosition.ts +++ b/src/connectors/uniswap/clmm-routes/closePosition.ts @@ -1,21 +1,17 @@ import { Contract } from '@ethersproject/contracts'; import { Percent, CurrencyAmount } from '@uniswap/sdk-core'; -import { NonfungiblePositionManager, Position } from '@uniswap/v3-sdk'; +import { NonfungiblePositionManager, Position, computePoolAddress } from '@uniswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - ClosePositionRequestType, - ClosePositionRequest, - ClosePositionResponseType, - ClosePositionResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { ClosePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { slippageBasisPoints } from '../../evm-slippage'; import { Uniswap } from '../uniswap'; -import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress } from '../uniswap.contracts'; +import { UniswapConfig } from '../uniswap.config'; +import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress, getUniswapV3FactoryAddress } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; // Default gas limit for CLMM close position operations @@ -25,6 +21,7 @@ export async function closePosition( network: string, walletAddress: string, positionAddress: string, + slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { // Validate essential parameters if (!positionAddress) { @@ -64,6 +61,16 @@ export async function closePosition( const token0 = await uniswap.getToken(position.token0); const token1 = await uniswap.getToken(position.token1); + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + factoryAddress: getUniswapV3FactoryAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); + // Determine base and quote tokens - WETH or lower address is base const isBaseToken0 = token0.symbol === 'WETH' || @@ -100,9 +107,10 @@ export async function closePosition( const amount1 = positionSDK.amount1; // Apply slippage tolerance - const slippageTolerance = new Percent(100, 10000); // 1% slippage - const amount0Min = amount0.multiply(new Percent(1).subtract(slippageTolerance)).quotient; - const amount1Min = amount1.multiply(new Percent(1).subtract(slippageTolerance)).quotient; + // The caller's tolerance, or the connector's configured one — not a literal. This was + // `new Percent(100, 10000)`, a flat 1% that ignored both, so an operator who had widened + // slippagePct for a volatile pair got 1% anyway and a revert that cost gas. + const slippageTolerance = new Percent(slippageBasisPoints(slippagePct), 10000); // Add any fees that have been collected to the expected amounts const totalAmount0 = CurrencyAmount.fromRawAmount( @@ -153,10 +161,11 @@ export async function closePosition( const tx = await positionManagerWithSigner.multicall([calldata], txParams); // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Calculate token amounts removed including fees const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); @@ -177,10 +186,11 @@ export async function closePosition( const positionRentRefunded = 0; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, positionRentRefunded, baseTokenAmountRemoved, quoteTokenAmountRemoved, @@ -189,46 +199,3 @@ export async function closePosition( }, }; } - -export const closePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ClosePositionRequestType; - Reply: ClosePositionResponseType; - }>( - '/close-position', - { - schema: { - description: 'Close a Uniswap V3 position by removing all liquidity and collecting fees', - tags: ['/connector/uniswap'], - body: ClosePositionRequest, - response: { - 200: ClosePositionResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress: requestedWalletAddress, positionAddress } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const uniswap = await Uniswap.getInstance(network); - walletAddress = await uniswap.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await closePosition(network, walletAddress, positionAddress); - } catch (e: any) { - logger.error('Failed to close position:', e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to close position'); - } - }, - ); -}; - -export default closePositionRoute; diff --git a/src/connectors/uniswap/clmm-routes/collectFees.ts b/src/connectors/uniswap/clmm-routes/collectFees.ts index 718d9bb8e8..1ad8076fae 100644 --- a/src/connectors/uniswap/clmm-routes/collectFees.ts +++ b/src/connectors/uniswap/clmm-routes/collectFees.ts @@ -1,20 +1,14 @@ import { Contract } from '@ethersproject/contracts'; import { CurrencyAmount } from '@uniswap/sdk-core'; -import { NonfungiblePositionManager } from '@uniswap/v3-sdk'; +import { NonfungiblePositionManager, computePoolAddress } from '@uniswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - CollectFeesRequestType, - CollectFeesRequest, - CollectFeesResponseType, - CollectFeesResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { CollectFeesResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { Uniswap } from '../uniswap'; -import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress } from '../uniswap.contracts'; +import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress, getUniswapV3FactoryAddress } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; // Default gas limit for CLMM collect fees operations @@ -63,6 +57,16 @@ export async function collectFees( const token0 = await uniswap.getToken(position.token0); const token1 = await uniswap.getToken(position.token1); + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + factoryAddress: getUniswapV3FactoryAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); + // Determine base and quote tokens - WETH or lower address is base const isBaseToken0 = token0.symbol === 'WETH' || @@ -114,10 +118,11 @@ export async function collectFees( const tx = await positionManagerWithSigner.multicall([calldata], txParams); // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the fee amounts below were read before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Calculate fee amounts collected const token0FeeAmount = formatTokenAmount(feeAmount0.toString(), token0.decimals); @@ -128,70 +133,13 @@ export async function collectFees( const quoteFeeAmountCollected = isBaseToken0 ? token1FeeAmount : token0FeeAmount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, baseFeeAmountCollected, quoteFeeAmountCollected, }, }; } - -export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: CollectFeesRequestType; - Reply: CollectFeesResponseType; - }>( - '/collect-fees', - { - schema: { - description: 'Collect fees from a Uniswap V3 position', - tags: ['/connector/uniswap'], - body: { - ...CollectFeesRequest, - properties: { - ...CollectFeesRequest.properties, - network: { type: 'string', default: 'base' }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - positionAddress: { - type: 'string', - description: 'Position NFT token ID', - examples: ['1234'], - }, - }, - }, - response: { - 200: CollectFeesResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress: requestedWalletAddress, positionAddress } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const uniswap = await Uniswap.getInstance(network); - walletAddress = await uniswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await collectFees(network, walletAddress, positionAddress); - } catch (e: any) { - logger.error('Failed to collect fees:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to collect fees'); - } - }, - ); -}; - -export default collectFeesRoute; diff --git a/src/connectors/uniswap/clmm-routes/createPool.ts b/src/connectors/uniswap/clmm-routes/createPool.ts index 7ef1320b2d..4137111146 100644 --- a/src/connectors/uniswap/clmm-routes/createPool.ts +++ b/src/connectors/uniswap/clmm-routes/createPool.ts @@ -1,16 +1,14 @@ import { Contract } from '@ethersproject/contracts'; -import { Static } from '@sinclair/typebox'; import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; import { Decimal } from 'decimal.js'; -import { BigNumber, constants, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; +import { BigNumber, constants } from 'ethers'; import JSBI from 'jsbi'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { UniswapClmmCreatePoolRequest } from '../schemas'; import { IUniswapV3FactoryABI, IUniswapV3PoolSlot0ABI, @@ -18,7 +16,6 @@ import { getUniswapV3FactoryAddress, getUniswapV3NftManagerAddress, } from '../uniswap.contracts'; -import { formatTokenAmount } from '../uniswap.utils'; // Uniswap V3 supported fee tiers (hundredths of a bip). 100=0.01%, 500=0.05%, 3000=0.30%, 10000=1.00%. const VALID_FEE_TIERS = [100, 500, 3000, 10000]; @@ -54,10 +51,10 @@ async function fetchMarketPrice( quoteToken: string, amount: number, ): Promise { - const { getUnifiedQuoteSwap } = await import('../../../trading/swap/quote'); + const { getSwapQuote } = await import('../../../trading/market-price'); let quote: any; try { - quote = await getUnifiedQuoteSwap(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); + quote = await getSwapQuote(`ethereum-${network}`, baseToken, quoteToken, amount, 'SELL'); } catch (e: any) { throw httpErrors.badRequest( `Could not fetch a market price for ${baseToken}/${quoteToken} to seed the pool (${e.message}). ` + @@ -77,8 +74,6 @@ export async function createPool( quoteToken: string, initialPrice?: number, fee?: number, - gasPrice?: number, - maxGas?: number, ): Promise { // Validate the fee tier — V3 only accepts a fixed set of tiers, each mapped to a tick spacing. if (fee === undefined) { @@ -176,7 +171,7 @@ export async function createPool( const nftManagerAddress = getUniswapV3NftManagerAddress(network); const nftManager = new Contract(nftManagerAddress, INftManagerCreatePoolABI, wallet); - const gasOptions = await ethereum.prepareGasOptions(gasPrice, maxGas || CLMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, CLMM_CREATE_POOL_GAS_LIMIT); const tx = await nftManager.createAndInitializePoolIfNecessary( token0.address, @@ -188,102 +183,29 @@ export async function createPool( logger.info(`Creating Uniswap V3 pool via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pool address from the factory — the authoritative source. const poolAddress: string = await factory.getPool(token0.address, token1.address, fee); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can reconcile it. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: 0, // create-pool only initializes price; no liquidity is seeded - quoteTokenAmountAdded: 0, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress, price: seedPrice, + data: { + fee: outcome.fee, + }, }; } - -export const createPoolRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.post<{ - Body: Static; - Reply: CreatePoolResponseType; - }>( - '/create-pool', - { - schema: { - description: 'Create and initialize a new Uniswap V3 (CLMM) pool at an initial price (no liquidity seeded)', - tags: ['/connector/uniswap'], - body: UniswapClmmCreatePoolRequest, - response: { - 200: CreatePoolResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - baseToken, - quoteToken, - fee, - initialPrice, - gasPrice, - maxGas, - walletAddress: requestedWalletAddress, - } = request.body; - - if (!baseToken || !quoteToken) { - throw fastify.httpErrors.badRequest('Missing required parameters'); - } - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - walletAddress = await Ethereum.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no wallets found.'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - // Route accepts gasPrice as a wei string (matching sibling requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - - return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, fee, gasPriceGwei, maxGas); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - - if (e.message && e.message.includes('already exists')) { - throw fastify.httpErrors.badRequest(e.message); - } - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw fastify.httpErrors.badRequest( - 'Insufficient ETH balance to pay for gas fees. Please add more ETH to your wallet.', - ); - } - - throw fastify.httpErrors.internalServerError('Failed to create pool'); - } - }, - ); -}; - -export default createPoolRoute; diff --git a/src/connectors/uniswap/clmm-routes/executeSwap.ts b/src/connectors/uniswap/clmm-routes/executeSwap.ts index 152d02e0d7..30fa3ee599 100644 --- a/src/connectors/uniswap/clmm-routes/executeSwap.ts +++ b/src/connectors/uniswap/clmm-routes/executeSwap.ts @@ -1,12 +1,11 @@ import { BigNumber, Contract, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; -import { UniswapExecuteSwapRequest } from '../schemas'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; import { getUniswapV3SwapRouter02Address, ISwapRouter02ABI } from '../uniswap.contracts'; @@ -103,7 +102,7 @@ export async function executeClmmSwap( sqrtPriceLimitX96: '0', }; - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -180,7 +179,7 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -242,19 +241,18 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction confirmed: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction confirmed: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -264,27 +262,22 @@ export async function executeClmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { @@ -318,52 +311,5 @@ export async function executeClmmSwap( } } -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap on Uniswap V3 CLMM using SwapRouter02', - tags: ['/connector/uniswap'], - body: UniswapExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct } = - request.body as typeof UniswapExecuteSwapRequest._type; - - // This route resolves the pool from the pair (no poolAddress in its request schema); - // executeClmmSwap itself is standardized to require poolAddress. - const uniswap = await Uniswap.getInstance(network); - const poolAddress = await uniswap.findDefaultPool(baseToken, quoteToken, 'clmm'); - if (!poolAddress) { - throw httpErrors.notFound(`No CLMM pool found for pair ${baseToken}-${quoteToken}`); - } - - return await executeClmmSwap( - network, - walletAddress, - poolAddress, - baseToken, - side as 'BUY' | 'SELL', - amount, - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - // Export executeSwap alias for uniform chain route imports export { executeClmmSwap as executeSwap }; - -export default executeSwapRoute; diff --git a/src/connectors/uniswap/clmm-routes/index.ts b/src/connectors/uniswap/clmm-routes/index.ts deleted file mode 100644 index 82c37be8fd..0000000000 --- a/src/connectors/uniswap/clmm-routes/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import addLiquidityRoute from './addLiquidity'; -import closePositionRoute from './closePosition'; -import collectFeesRoute from './collectFees'; -import createPoolRoute from './createPool'; -import executeSwapRoute from './executeSwap'; -import openPositionRoute from './openPosition'; -import poolInfoRoute from './poolInfo'; -import positionInfoRoute from './positionInfo'; -import positionsOwnedRoute from './positionsOwned'; -import quotePositionRoute from './quotePosition'; -import quoteSwapRoute from './quoteSwap'; -import removeLiquidityRoute from './removeLiquidity'; - -export const uniswapClmmRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(poolInfoRoute); - await fastify.register(positionInfoRoute); - await fastify.register(positionsOwnedRoute); - await fastify.register(quotePositionRoute); - await fastify.register(quoteSwapRoute); - await fastify.register(executeSwapRoute); - await fastify.register(openPositionRoute); - await fastify.register(createPoolRoute); - await fastify.register(addLiquidityRoute); - await fastify.register(removeLiquidityRoute); - await fastify.register(collectFeesRoute); - await fastify.register(closePositionRoute); -}; - -export default uniswapClmmRoutes; diff --git a/src/connectors/uniswap/clmm-routes/openPosition.ts b/src/connectors/uniswap/clmm-routes/openPosition.ts index 108987b81f..81a5847b34 100644 --- a/src/connectors/uniswap/clmm-routes/openPosition.ts +++ b/src/connectors/uniswap/clmm-routes/openPosition.ts @@ -2,19 +2,14 @@ import { Contract } from '@ethersproject/contracts'; import { CurrencyAmount, Percent } from '@uniswap/sdk-core'; import { Position, NonfungiblePositionManager, MintOptions, nearestUsableTick } from '@uniswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; // Default gas limit for CLMM open position operations const CLMM_OPEN_POSITION_GAS_LIMIT = 600000; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - OpenPositionRequestType, - OpenPositionRequest, - OpenPositionResponseType, - OpenPositionResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { OpenPositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -235,11 +230,15 @@ export async function openPosition( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — there is no mint log to read yet, so no positionAddress and no amounts. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Find the NFT ID from the transaction logs let positionId = ''; - for (const log of receipt.logs) { + for (const log of outcome.receipt.logs) { if ( log.address.toLowerCase() === positionManagerAddress.toLowerCase() && log.topics[0] === '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' && @@ -250,8 +249,15 @@ export async function openPosition( } } - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + if (!positionId) { + // The transaction confirmed but no NFT-mint Transfer log was found. Returning a CONFIRMED + // response with an empty positionAddress would have the caller record a position it can + // never address — fail loudly, naming the transaction so the position stays recoverable. + throw httpErrors.internalServerError( + `Position opened in transaction ${outcome.signature} but no position NFT mint was found in its logs. ` + + `Inspect the transaction to recover the position ID.`, + ); + } // For position rent, we're using the estimated gas cost since Ethereum doesn't have rent like Solana const positionRent = 0; @@ -265,10 +271,10 @@ export async function openPosition( const quoteAmountUsed = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, positionAddress: positionId, positionRent, baseTokenAmountAdded: baseAmountUsed, @@ -276,99 +282,3 @@ export async function openPosition( }, }; } - -export const openPositionRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: OpenPositionRequestType; - Reply: OpenPositionResponseType; - }>( - '/open-position', - { - schema: { - description: 'Open a new liquidity position in a Uniswap V3 pool', - tags: ['/connector/uniswap'], - body: { - ...OpenPositionRequest, - properties: { - ...OpenPositionRequest.properties, - network: { type: 'string', default: 'base' }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - lowerPrice: { type: 'number', examples: [1000] }, - upperPrice: { type: 'number', examples: [4000] }, - poolAddress: { type: 'string', examples: ['0xd0b53d9277642d899df5c87a3966a349a798f224'] }, - baseTokenAmount: { type: 'number', examples: [0.001] }, - quoteTokenAmount: { type: 'number', examples: [3] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { - 200: OpenPositionResponse, - }, - }, - }, - async (request) => { - try { - const { - network, - walletAddress: requestedWalletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - } = request.body; - - // Get wallet address - either from request or first available - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const uniswap = await Uniswap.getInstance(network); - walletAddress = await uniswap.getFirstWalletAddress(); - if (!walletAddress) { - throw httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - logger.info(`Using first available wallet address: ${walletAddress}`); - } - - return await openPosition( - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - } catch (e: any) { - logger.error('Failed to open position:', e); - - // If error already has statusCode, re-throw it - if (e.statusCode) { - throw e; - } - - // Check for specific error types - if (e.code === 'CALL_EXCEPTION') { - throw httpErrors.badRequest( - 'Transaction failed. Please check token balances, approvals, and position parameters.', - ); - } - - // Handle insufficient funds errors - if (e.code === 'INSUFFICIENT_FUNDS' || (e.message && e.message.includes('insufficient funds'))) { - throw httpErrors.badRequest('Insufficient funds to complete the transaction'); - } - - // Generic error - throw httpErrors.internalServerError('Failed to open position'); - } - }, - ); -}; - -export default openPositionRoute; diff --git a/src/connectors/uniswap/clmm-routes/poolInfo.ts b/src/connectors/uniswap/clmm-routes/poolInfo.ts index 46c709b14b..e3f4907fad 100644 --- a/src/connectors/uniswap/clmm-routes/poolInfo.ts +++ b/src/connectors/uniswap/clmm-routes/poolInfo.ts @@ -1,14 +1,11 @@ import { Contract as EthersProjectContract } from '@ethersproject/contracts'; import { abi as IUniswapV3PoolABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json'; -import { FeeAmount } from '@uniswap/v3-sdk'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { PoolInfo } from '../../../schemas/clmm-schema'; import { sanitizeErrorMessage } from '../../../services/sanitize'; -import { UniswapClmmGetPoolInfoRequest, UniswapClmmGetPoolInfoRequestType } from '../schemas'; import { Uniswap } from '../uniswap'; import { computeUniswapBinDistribution, formatTokenAmount, getUniswapPoolInfo } from '../uniswap.utils'; @@ -118,36 +115,3 @@ export async function getPoolInfo( return result; } - -export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: UniswapClmmGetPoolInfoRequestType; - Reply: Record; - }>( - '/pool-info', - { - schema: { - description: 'Get CLMM pool information from Uniswap V3', - tags: ['/connector/uniswap'], - querystring: UniswapClmmGetPoolInfoRequest, - response: { - 200: PoolInfoSchema, - }, - }, - }, - async (request): Promise => { - try { - const { poolAddress, binCount = 0, network } = request.query; - return await getPoolInfo(fastify, network, poolAddress, binCount); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to fetch pool info'); - } - }, - ); -}; - -export default poolInfoRoute; diff --git a/src/connectors/uniswap/clmm-routes/positionInfo.ts b/src/connectors/uniswap/clmm-routes/positionInfo.ts index 64eef22a41..444dca8c1c 100644 --- a/src/connectors/uniswap/clmm-routes/positionInfo.ts +++ b/src/connectors/uniswap/clmm-routes/positionInfo.ts @@ -1,23 +1,9 @@ import { Contract } from '@ethersproject/contracts'; -import { Token } from '@uniswap/sdk-core'; -import { - Position, - NonfungiblePositionManager, - tickToPrice, - computePoolAddress, - FACTORY_ADDRESS, -} from '@uniswap/v3-sdk'; -import { BigNumber } from 'ethers'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { Position, tickToPrice, computePoolAddress } from '@uniswap/v3-sdk'; +import { FastifyInstance } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - GetPositionInfoRequestType, - GetPositionInfoRequest, - PositionInfo, - PositionInfoSchema, -} from '../../../schemas/clmm-schema'; -import { logger } from '../../../services/logger'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { Uniswap } from '../uniswap'; import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress, getUniswapV3FactoryAddress } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; @@ -125,48 +111,3 @@ export async function getPositionInfo( price: parseFloat(price), }; } - -export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - fastify.get<{ - Querystring: GetPositionInfoRequestType; - Reply: PositionInfo; - }>( - '/position-info', - { - schema: { - description: 'Get position information for a Uniswap V3 position', - tags: ['/connector/uniswap'], - querystring: { - ...GetPositionInfoRequest, - properties: { - network: { type: 'string', default: 'base' }, - positionAddress: { - type: 'string', - description: 'Position NFT token ID', - examples: ['1234'], - }, - }, - }, - response: { - 200: PositionInfoSchema, - }, - }, - }, - async (request) => { - try { - const { network, positionAddress } = request.query; - return await getPositionInfo(fastify, network, positionAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to get position info'); - } - }, - ); -}; - -export default positionInfoRoute; diff --git a/src/connectors/uniswap/clmm-routes/positionsOwned.ts b/src/connectors/uniswap/clmm-routes/positionsOwned.ts index c3464ce0f5..e846d5bedb 100644 --- a/src/connectors/uniswap/clmm-routes/positionsOwned.ts +++ b/src/connectors/uniswap/clmm-routes/positionsOwned.ts @@ -1,23 +1,14 @@ import { Contract } from '@ethersproject/contracts'; -import { Type } from '@sinclair/typebox'; import { Position, tickToPrice, computePoolAddress } from '@uniswap/v3-sdk'; -import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { PositionInfo, PositionInfoSchema } from '../../../schemas/clmm-schema'; +import { PositionInfo } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Uniswap } from '../uniswap'; import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress, getUniswapV3FactoryAddress } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; -// Define the request and response types -const PositionsOwnedRequest = Type.Object({ - network: Type.Optional(Type.String({ examples: ['base'], default: 'base' })), - walletAddress: Type.String({ examples: [''] }), -}); - -const PositionsOwnedResponse = Type.Array(PositionInfoSchema); - // Additional ABI methods needed for enumerating positions const ENUMERABLE_ABI = [ { @@ -177,46 +168,3 @@ export async function getPositionsOwned( return positions; } - -export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.get<{ - Querystring: typeof PositionsOwnedRequest.static; - Reply: typeof PositionsOwnedResponse.static; - }>( - '/positions-owned', - { - schema: { - description: 'Get all Uniswap V3 positions owned by a wallet', - tags: ['/connector/uniswap'], - querystring: { - ...PositionsOwnedRequest, - properties: { - ...PositionsOwnedRequest.properties, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - }, - }, - response: { - 200: PositionsOwnedResponse, - }, - }, - }, - async (request) => { - try { - const { walletAddress } = request.query; - const network = request.query.network; - return await getPositionsOwned(fastify, network, walletAddress); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to fetch positions'); - } - }, - ); -}; - -export default positionsOwnedRoute; diff --git a/src/connectors/uniswap/clmm-routes/quotePosition.ts b/src/connectors/uniswap/clmm-routes/quotePosition.ts index 099f8bb274..39299b6a52 100644 --- a/src/connectors/uniswap/clmm-routes/quotePosition.ts +++ b/src/connectors/uniswap/clmm-routes/quotePosition.ts @@ -1,397 +1,12 @@ -import { Token, CurrencyAmount } from '@uniswap/sdk-core'; -import { - Position, - Pool as V3Pool, - nearestUsableTick, - tickToPrice, - priceToClosestTick, - FeeAmount, -} from '@uniswap/v3-sdk'; -import { FastifyPluginAsync } from 'fastify'; +import { Position, nearestUsableTick } from '@uniswap/v3-sdk'; import JSBI from 'jsbi'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - QuotePositionRequestType, - QuotePositionRequest, - QuotePositionResponseType, - QuotePositionResponse, -} from '../../../schemas/clmm-schema'; +import { QuotePositionResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Uniswap } from '../uniswap'; -import { parseFeeTier, getUniswapPoolInfo } from '../uniswap.utils'; - +import { getUniswapPoolInfo } from '../uniswap.utils'; // Constants for examples (Base WETH-USDC pool) -const BASE_TOKEN_AMOUNT = 0.001; -const QUOTE_TOKEN_AMOUNT = 3; -const LOWER_PRICE_BOUND = 2000; -const UPPER_PRICE_BOUND = 4000; -const POOL_ADDRESS_EXAMPLE = '0xd0b53d9277642d899df5c87a3966a349a798f224'; - -export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: QuotePositionRequestType; - Reply: QuotePositionResponseType; - }>( - '/quote-position', - { - schema: { - description: 'Get a quote for opening a position on Uniswap V3', - tags: ['/connector/uniswap'], - querystring: { - ...QuotePositionRequest, - properties: { - ...QuotePositionRequest.properties, - network: { type: 'string', default: 'base', examples: ['base'] }, - lowerPrice: { type: 'number', examples: [LOWER_PRICE_BOUND] }, - upperPrice: { type: 'number', examples: [UPPER_PRICE_BOUND] }, - poolAddress: { - type: 'string', - default: POOL_ADDRESS_EXAMPLE, - examples: [POOL_ADDRESS_EXAMPLE], - }, - baseTokenAmount: { type: 'number', examples: [BASE_TOKEN_AMOUNT] }, - quoteTokenAmount: { type: 'number', examples: [QUOTE_TOKEN_AMOUNT] }, - }, - }, - response: { - 200: QuotePositionResponse, - }, - }, - }, - async (request) => { - try { - const { network, lowerPrice, upperPrice, poolAddress, baseTokenAmount, quoteTokenAmount } = request.query; - - const networkToUse = network; - const chain = 'ethereum'; // Default to ethereum - - // Validate essential parameters - if ( - !lowerPrice || - !upperPrice || - !poolAddress || - (baseTokenAmount === undefined && quoteTokenAmount === undefined) - ) { - throw httpErrors.badRequest('Missing required parameters'); - } - - // Get Uniswap and Ethereum instances - const uniswap = await Uniswap.getInstance(networkToUse); - const ethereum = await Ethereum.getInstance(networkToUse); - - // Get pool information to determine tokens - const poolInfo = await getUniswapPoolInfo(poolAddress, networkToUse, 'clmm'); - if (!poolInfo) { - throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddress)); - } - - const baseTokenObj = await uniswap.getToken(poolInfo.baseTokenAddress); - const quoteTokenObj = await uniswap.getToken(poolInfo.quoteTokenAddress); - - if (!baseTokenObj || !quoteTokenObj) { - throw httpErrors.badRequest('Token information not found for pool'); - } - - // Get the V3 pool - const pool = await uniswap.getV3Pool(baseTokenObj, quoteTokenObj, undefined, poolAddress); - if (!pool) { - throw httpErrors.notFound(`Pool not found for ${baseTokenObj.symbol}-${quoteTokenObj.symbol}`); - } - - // Convert price range to ticks - // In Uniswap, ticks are log base 1.0001 of price - // We need to convert the user's desired price range to tick range - const token0 = pool.token0; - const token1 = pool.token1; - - // Determine if we need to invert the price depending on which token is token0 - const isBaseToken0 = baseTokenObj.address.toLowerCase() === token0.address.toLowerCase(); - - // Convert prices to ticks - let lowerTick, upperTick; - - // Calculate ticks based on price - // Tick = log(price) / log(1.0001) - const priceToTick = (price: number): number => { - return Math.floor(Math.log(price) / Math.log(1.0001)); - }; - - console.log('DEBUG: isBaseToken0:', isBaseToken0); - console.log('DEBUG: baseToken symbol:', baseTokenObj.symbol, 'address:', baseTokenObj.address); - console.log('DEBUG: quoteToken symbol:', quoteTokenObj.symbol, 'address:', quoteTokenObj.address); - console.log('DEBUG: token0:', token0.symbol, 'address:', token0.address); - console.log('DEBUG: token1:', token1.symbol, 'address:', token1.address); - - // CRITICAL INSIGHT: The pool's negative tick is confusing us! - // The pool tick of -197547 actually represents the current price correctly - // but in a way that seems counterintuitive. - // - // The issue is that Uniswap stores the price and tick in a specific way: - // - sqrtPriceX96 = sqrt(token1/token0) * 2^96 - // - tick = floor(log(token1/token0) / log(1.0001)) - // - // For this pool: - // - token0 = WETH (18 decimals) - // - token1 = USDC (6 decimals) - // - Human readable price = 2637 USDC per WETH - // - But in raw amounts: 2637 * 10^6 USDC units per 10^18 WETH units - // - So token1/token0 in raw units = (2637 * 10^6) / 10^18 = 2637 * 10^-12 - // - This is a very small number! Hence the negative tick. - - console.log('DEBUG: Current pool tick:', pool.tickCurrent); - console.log('DEBUG: This tick represents token1/token0 in RAW UNITS (not human readable)'); - - // When calculating ticks from human-readable prices, we need to account for decimals - const priceToTickWithDecimals = (humanPrice: number): number => { - // Convert human price (USDC per WETH) to raw price (USDC units per WETH unit) - const rawPrice = humanPrice * Math.pow(10, token1.decimals - token0.decimals); - return Math.floor(Math.log(rawPrice) / Math.log(1.0001)); - }; - - lowerTick = priceToTickWithDecimals(lowerPrice); - upperTick = priceToTickWithDecimals(upperPrice); - - const currentHumanPrice = 2637; // Approximate current price - const expectedCurrentTick = priceToTickWithDecimals(currentHumanPrice); - console.log('DEBUG: Expected current tick for price', currentHumanPrice, ':', expectedCurrentTick); - console.log('DEBUG: Lower price', lowerPrice, '-> tick', lowerTick); - console.log('DEBUG: Upper price', upperPrice, '-> tick', upperTick); - - console.log('DEBUG: Raw calculated lowerTick:', lowerTick); - console.log('DEBUG: Raw calculated upperTick:', upperTick); - - // Ensure ticks are on valid tick spacing boundaries - const tickSpacing = pool.tickSpacing; - lowerTick = nearestUsableTick(lowerTick, tickSpacing); - upperTick = nearestUsableTick(upperTick, tickSpacing); - - console.log('DEBUG: Adjusted lowerTick (after tick spacing):', lowerTick); - console.log('DEBUG: Adjusted upperTick (after tick spacing):', upperTick); - console.log('DEBUG: Pool tick spacing:', tickSpacing); - console.log('DEBUG: Current pool tick:', pool.tickCurrent); - console.log('DEBUG: Pool current price (sqrtPriceX96):', pool.sqrtRatioX96.toString()); - - // Calculate the actual price from sqrtPriceX96 - const sqrtPriceX96 = JSBI.toNumber(pool.sqrtRatioX96); - const price = Math.pow(sqrtPriceX96 / Math.pow(2, 96), 2); - console.log('DEBUG: Pool current price (decimal):', price); - console.log( - 'DEBUG: Pool current price (token1/token0):', - price * Math.pow(10, token0.decimals - token1.decimals), - ); - - // Use SDK to convert tick to price for verification - const tickPrice = tickToPrice(token0, token1, pool.tickCurrent); - console.log('DEBUG: Price from current tick:', tickPrice.toSignificant(6)); - console.log('DEBUG: Price from current tick (inverted):', tickPrice.invert().toSignificant(6)); - - // Ensure lower < upper - if (lowerTick >= upperTick) { - throw httpErrors.badRequest('Lower price must be less than upper price'); - } - - // Check if the current price is within the position range - const isInRange = pool.tickCurrent >= lowerTick && pool.tickCurrent <= upperTick; - console.log('DEBUG: Is position in range?', isInRange); - console.log('DEBUG: Position will require both tokens?', isInRange); - - if (!isInRange) { - console.log('WARNING: Position is out of range!'); - console.log( - ' Current tick:', - pool.tickCurrent, - 'is', - pool.tickCurrent < lowerTick ? 'below' : 'above', - 'the range', - ); - console.log( - ' This means the position will only contain', - pool.tickCurrent < lowerTick ? baseTokenObj.symbol : quoteTokenObj.symbol, - ); - } - - // Calculate optimal token amounts - let position: Position; - let baseLimited = false; - - console.log('DEBUG: Input amounts:'); - console.log(' - baseTokenAmount:', baseTokenAmount); - console.log(' - quoteTokenAmount:', quoteTokenAmount); - - if (baseTokenAmount !== undefined && quoteTokenAmount !== undefined) { - console.log('DEBUG: Using fromAmounts (both amounts provided)'); - // Both amounts provided - use fromAmounts to calculate optimal position - const baseAmountRaw = JSBI.BigInt( - Math.floor(baseTokenAmount * Math.pow(10, baseTokenObj.decimals)).toString(), - ); - const quoteAmountRaw = JSBI.BigInt( - Math.floor(quoteTokenAmount * Math.pow(10, quoteTokenObj.decimals)).toString(), - ); - - console.log('DEBUG: Raw amounts:'); - console.log(' - baseAmountRaw:', baseAmountRaw.toString()); - console.log(' - quoteAmountRaw:', quoteAmountRaw.toString()); - console.log(' - baseToken decimals:', baseTokenObj.decimals); - console.log(' - quoteToken decimals:', quoteTokenObj.decimals); - - // Create position from both amounts - if (isBaseToken0) { - console.log('DEBUG: Creating position with base as token0'); - position = Position.fromAmounts({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: baseAmountRaw, - amount1: quoteAmountRaw, - useFullPrecision: true, - }); - } else { - console.log('DEBUG: Creating position with base as token1'); - position = Position.fromAmounts({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: quoteAmountRaw, - amount1: baseAmountRaw, - useFullPrecision: true, - }); - } - - // Determine which token is limiting by comparing input vs required amounts - const baseRequired = isBaseToken0 ? position.amount0 : position.amount1; - const quoteRequired = isBaseToken0 ? position.amount1 : position.amount0; - - const baseRatio = parseFloat(baseAmountRaw.toString()) / parseFloat(baseRequired.quotient.toString()); - const quoteRatio = parseFloat(quoteAmountRaw.toString()) / parseFloat(quoteRequired.quotient.toString()); - - baseLimited = baseRatio <= quoteRatio; - } else if (baseTokenAmount !== undefined) { - console.log('DEBUG: Using fromAmount (only base amount provided)'); - // Only base amount provided - const baseAmountRaw = JSBI.BigInt( - Math.floor(baseTokenAmount * Math.pow(10, baseTokenObj.decimals)).toString(), - ); - - console.log('DEBUG: baseAmountRaw:', baseAmountRaw.toString()); - - if (isBaseToken0) { - console.log('DEBUG: Creating position from amount0 (base is token0)'); - position = Position.fromAmount0({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: baseAmountRaw, - useFullPrecision: true, - }); - } else { - console.log('DEBUG: Creating position from amount1 (base is token1)'); - position = Position.fromAmount1({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount1: baseAmountRaw, - }); - } - baseLimited = true; - } else if (quoteTokenAmount !== undefined) { - console.log('DEBUG: Using fromAmount (only quote amount provided)'); - // Only quote amount provided - const quoteAmountRaw = JSBI.BigInt( - Math.floor(quoteTokenAmount * Math.pow(10, quoteTokenObj.decimals)).toString(), - ); - - console.log('DEBUG: quoteAmountRaw:', quoteAmountRaw.toString()); - - if (isBaseToken0) { - console.log('DEBUG: Creating position from amount1 (quote is token1)'); - position = Position.fromAmount1({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount1: quoteAmountRaw, - }); - } else { - console.log('DEBUG: Creating position from amount0 (quote is token0)'); - position = Position.fromAmount0({ - pool, - tickLower: lowerTick, - tickUpper: upperTick, - amount0: quoteAmountRaw, - useFullPrecision: true, - }); - } - baseLimited = false; - } else { - throw httpErrors.badRequest('Either base or quote token amount must be provided'); - } - - // Calculate the optimal amounts - const optimalToken0Amount = position.mintAmounts.amount0; - const optimalToken1Amount = position.mintAmounts.amount1; - - // Get the actual token amounts from the position - const actualToken0Amount = position.amount0; - const actualToken1Amount = position.amount1; - - console.log('DEBUG: Position created with:'); - console.log(' - liquidity:', position.liquidity.toString()); - console.log(' - amount0 (raw):', actualToken0Amount.quotient.toString()); - console.log(' - amount1 (raw):', actualToken1Amount.quotient.toString()); - console.log(' - amount0 (formatted):', actualToken0Amount.toSignificant(18)); - console.log(' - amount1 (formatted):', actualToken1Amount.toSignificant(18)); - console.log(' - mintAmounts.amount0:', position.mintAmounts.amount0.toString()); - console.log(' - mintAmounts.amount1:', position.mintAmounts.amount1.toString()); - - // Calculate actual amounts in human-readable form - let actualBaseAmount, actualQuoteAmount; - - if (isBaseToken0) { - actualBaseAmount = parseFloat(actualToken0Amount.toSignificant(18)); - actualQuoteAmount = parseFloat(actualToken1Amount.toSignificant(18)); - } else { - actualBaseAmount = parseFloat(actualToken1Amount.toSignificant(18)); - actualQuoteAmount = parseFloat(actualToken0Amount.toSignificant(18)); - } - - console.log('DEBUG: Final amounts:'); - console.log(' - actualBaseAmount:', actualBaseAmount); - console.log(' - actualQuoteAmount:', actualQuoteAmount); - console.log(' - baseLimited:', baseLimited); - - // Calculate max amounts - const baseTokenAmountMax = baseTokenAmount || actualBaseAmount; - const quoteTokenAmountMax = quoteTokenAmount || actualQuoteAmount; - - // Calculate liquidity value - const liquidity = position.liquidity.toString(); - - // Use standard gas limit for position operations - const computeUnits = 500000; - - return { - baseLimited, - baseTokenAmount: actualBaseAmount, - quoteTokenAmount: actualQuoteAmount, - baseTokenAmountMax, - quoteTokenAmountMax, - liquidity, - computeUnits, - }; - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to quote position'); - } - }, - ); -}; - -export default quotePositionRoute; // Export standalone function for use in unified routes export async function quotePosition( @@ -410,7 +25,6 @@ export async function quotePosition( // Get Uniswap and Ethereum instances const uniswap = await Uniswap.getInstance(network); - const ethereum = await Ethereum.getInstance(network); // Get pool information to determine tokens const poolInfo = await getUniswapPoolInfo(poolAddress, network, 'clmm'); diff --git a/src/connectors/uniswap/clmm-routes/quoteSwap.ts b/src/connectors/uniswap/clmm-routes/quoteSwap.ts index bf2761da26..5754cd8ae9 100644 --- a/src/connectors/uniswap/clmm-routes/quoteSwap.ts +++ b/src/connectors/uniswap/clmm-routes/quoteSwap.ts @@ -1,23 +1,16 @@ import { Token, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core'; -import { Pool as V3Pool, SwapQuoter, SwapOptions, Route as V3Route, Trade as V3Trade } from '@uniswap/v3-sdk'; +import { Route as V3Route, Trade as V3Trade } from '@uniswap/v3-sdk'; import { BigNumber, utils } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - QuoteSwapRequestType, - QuoteSwapResponseType, - QuoteSwapRequest, - QuoteSwapResponse, -} from '../../../schemas/clmm-schema'; +import { QuoteSwapResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; -import { formatTokenAmount, parseFeeTier, getUniswapPoolInfo } from '../uniswap.utils'; - +import { formatTokenAmount, getUniswapPoolInfo } from '../uniswap.utils'; async function quoteClmmSwap( uniswap: Uniswap, poolAddress: string, @@ -190,7 +183,7 @@ async function formatSwapQuote( try { // Use the extracted quote function - const { quote, uniswap, ethereum, baseTokenObj, quoteTokenObj } = await getUniswapClmmQuote( + const { quote, ethereum } = await getUniswapClmmQuote( network, poolAddress, baseToken, @@ -239,14 +232,12 @@ async function formatSwapQuote( const priceImpactPct = quote.priceImpact; // Get current tick from pool - const activeBinId = quote.currentTick || 0; // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; // Calculate fee (V3 has dynamic fees based on pool) - const fee = quote.estimatedAmountIn * (quote.feeTier / 1000000); return { // Base QuoteSwapResponse fields in correct order @@ -271,124 +262,6 @@ async function formatSwapQuote( } } -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - // Import the httpErrors plugin to ensure it's available - await fastify.register(require('@fastify/sensible')); - - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get swap quote for Uniswap V3 CLMM', - tags: ['/connector/uniswap'], - querystring: { - ...QuoteSwapRequest, - properties: { - ...QuoteSwapRequest.properties, - network: { type: 'string', default: 'base' }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, - amount: { type: 'number', examples: [0.001] }, - side: { type: 'string', enum: ['BUY', 'SELL'], examples: ['SELL'] }, - slippagePct: { type: 'number', examples: [1] }, - }, - }, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { network, poolAddress, baseToken, quoteToken, amount, side, slippagePct } = request.query; - - const networkToUse = network; - - // Validate essential parameters - if (!baseToken || !amount || !side) { - throw httpErrors.badRequest('baseToken, amount, and side are required'); - } - - const uniswap = await Uniswap.getInstance(networkToUse); - - let poolAddressToUse = poolAddress; - let baseTokenToUse: string; - let quoteTokenToUse: string; - - if (poolAddressToUse) { - // Pool address provided, get pool info to determine tokens - const poolInfo = await getUniswapPoolInfo(poolAddressToUse, networkToUse, 'clmm'); - if (!poolInfo) { - throw httpErrors.notFound(sanitizeErrorMessage('Pool not found: {}', poolAddressToUse)); - } - - // Determine which token is base and which is quote based on the provided baseToken - if (baseToken === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (baseToken === poolInfo.quoteTokenAddress) { - // User specified the quote token as base, so swap them - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - // Try to resolve baseToken as symbol to address - const resolvedToken = await uniswap.getToken(baseToken); - - if (resolvedToken) { - if (resolvedToken.address === poolInfo.baseTokenAddress) { - baseTokenToUse = poolInfo.baseTokenAddress; - quoteTokenToUse = poolInfo.quoteTokenAddress; - } else if (resolvedToken.address === poolInfo.quoteTokenAddress) { - baseTokenToUse = poolInfo.quoteTokenAddress; - quoteTokenToUse = poolInfo.baseTokenAddress; - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } else { - throw httpErrors.badRequest(`Token ${baseToken} not found in pool ${poolAddressToUse}`); - } - } - } else { - // No pool address provided, need quoteToken to find pool - if (!quoteToken) { - throw httpErrors.badRequest('quoteToken is required when poolAddress is not provided'); - } - - baseTokenToUse = baseToken; - quoteTokenToUse = quoteToken; - - // Find pool using findDefaultPool - poolAddressToUse = await uniswap.findDefaultPool(baseTokenToUse, quoteTokenToUse, 'clmm'); - - if (!poolAddressToUse) { - throw httpErrors.notFound(`No CLMM pool found for pair ${baseTokenToUse}-${quoteTokenToUse}`); - } - } - - return await formatSwapQuote( - networkToUse, - poolAddressToUse, - baseTokenToUse, - quoteTokenToUse, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - logger.error(e); - if (e.statusCode) { - throw e; - } - logger.error('Unexpected error getting swap quote:', e); - throw httpErrors.internalServerError('Error getting swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; - /** * Resolves the counter ("quote") token for a Uniswap V3 pool given the base token. The standardized * swap wrappers take poolAddress + baseToken and derive the other side from the pool, so callers no diff --git a/src/connectors/uniswap/clmm-routes/removeLiquidity.ts b/src/connectors/uniswap/clmm-routes/removeLiquidity.ts index 0198ea2d42..517694136e 100644 --- a/src/connectors/uniswap/clmm-routes/removeLiquidity.ts +++ b/src/connectors/uniswap/clmm-routes/removeLiquidity.ts @@ -1,21 +1,17 @@ import { Contract } from '@ethersproject/contracts'; import { Percent, CurrencyAmount } from '@uniswap/sdk-core'; -import { NonfungiblePositionManager, Position } from '@uniswap/v3-sdk'; +import { NonfungiblePositionManager, Position, computePoolAddress } from '@uniswap/v3-sdk'; import { BigNumber } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { - RemoveLiquidityRequestType, - RemoveLiquidityRequest, - RemoveLiquidityResponseType, - RemoveLiquidityResponse, -} from '../../../schemas/clmm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { RemoveLiquidityResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; -import { logger } from '../../../services/logger'; +import { slippageBasisPoints } from '../../evm-slippage'; import { Uniswap } from '../uniswap'; -import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress } from '../uniswap.contracts'; +import { UniswapConfig } from '../uniswap.config'; +import { POSITION_MANAGER_ABI, getUniswapV3NftManagerAddress, getUniswapV3FactoryAddress } from '../uniswap.contracts'; import { formatTokenAmount } from '../uniswap.utils'; // Default gas limit for CLMM remove liquidity operations @@ -26,6 +22,7 @@ export async function removeLiquidity( walletAddress: string, positionAddress: string, percentageToRemove: number, + slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { // Validate essential parameters if (!positionAddress || percentageToRemove === undefined) { @@ -69,6 +66,16 @@ export async function removeLiquidity( const token0 = await uniswap.getToken(position.token0); const token1 = await uniswap.getToken(position.token1); + // The pool this position belongs to, derived from the same inputs position-info + // uses. The unified route is position-addressed and never receives a pool, so + // deriving it here is what lets the response name the venue it acted on. + const poolAddress = computePoolAddress({ + factoryAddress: getUniswapV3FactoryAddress(network), + tokenA: token0, + tokenB: token1, + fee: position.fee, + }); + // Determine base and quote tokens - WETH or lower address is base const isBaseToken0 = token0.symbol === 'WETH' || @@ -77,9 +84,6 @@ export async function removeLiquidity( // Get current liquidity const currentLiquidity = position.liquidity; - // Calculate liquidity to remove based on percentage - const liquidityToRemove = currentLiquidity.mul(Math.floor(percentageToRemove * 100)).div(10000); - // Get the pool const pool = await uniswap.getV3Pool(token0, token1, position.fee); if (!pool) { @@ -111,9 +115,10 @@ export async function removeLiquidity( const amount1 = partialPosition.amount1; // Apply slippage tolerance - const slippageTolerance = new Percent(100, 10000); // 1% slippage - const amount0Min = amount0.multiply(new Percent(1).subtract(slippageTolerance)).quotient; - const amount1Min = amount1.multiply(new Percent(1).subtract(slippageTolerance)).quotient; + // The caller's tolerance, or the connector's configured one — not a literal. This was + // `new Percent(100, 10000)`, a flat 1% that ignored both, so an operator who had widened + // slippagePct for a volatile pair got 1% anyway and a revert that cost gas. + const slippageTolerance = new Percent(slippageBasisPoints(slippagePct), 10000); // Also add any fees that have been collected to the expected amounts const totalAmount0 = CurrencyAmount.fromRawAmount( @@ -164,10 +169,11 @@ export async function removeLiquidity( const tx = await positionManagerWithSigner.multicall([calldata], txParams); // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Calculate token amounts removed including fees const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); @@ -178,77 +184,13 @@ export async function removeLiquidity( const quoteTokenAmountRemoved = isBaseToken0 ? token1AmountRemoved : token0AmountRemoved; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + poolAddress, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, }; } - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(require('@fastify/sensible')); - - const walletAddressExample = await Ethereum.getWalletAddressExample(); - - fastify.post<{ - Body: RemoveLiquidityRequestType; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from a Uniswap V3 position', - tags: ['/connector/uniswap'], - body: { - ...RemoveLiquidityRequest, - properties: { - ...RemoveLiquidityRequest.properties, - network: { type: 'string', default: 'base' }, - walletAddress: { type: 'string', examples: [walletAddressExample] }, - positionAddress: { - type: 'string', - description: 'Position NFT token ID', - examples: ['1234'], - }, - percentageToRemove: { - type: 'number', - minimum: 0, - maximum: 100, - examples: [50], - }, - }, - }, - response: { - 200: RemoveLiquidityResponse, - }, - }, - }, - async (request) => { - try { - const { network, walletAddress: requestedWalletAddress, positionAddress, percentageToRemove } = request.body; - - let walletAddress = requestedWalletAddress; - if (!walletAddress) { - const uniswap = await Uniswap.getInstance(network); - walletAddress = await uniswap.getFirstWalletAddress(); - if (!walletAddress) { - throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet found'); - } - } - - return await removeLiquidity(network, walletAddress, positionAddress, percentageToRemove); - } catch (e: any) { - logger.error('Failed to remove liquidity:', e); - if (e.statusCode) { - throw e; - } - throw fastify.httpErrors.internalServerError('Failed to remove liquidity'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/connectors/uniswap/router-routes/executeQuote.ts b/src/connectors/uniswap/router-routes/executeQuote.ts index 7f6e4f8739..bca02379da 100644 --- a/src/connectors/uniswap/router-routes/executeQuote.ts +++ b/src/connectors/uniswap/router-routes/executeQuote.ts @@ -1,15 +1,12 @@ import { BigNumber, utils, ethers } from 'ethers'; -import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; -import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; import { MAX_UINT48 } from '../../../chains/ethereum/routes/approve'; -import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; -import { UniswapExecuteQuoteRequest } from '../schemas'; // Permit2 address is constant across all chains const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; @@ -22,7 +19,7 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str } const { quote, request } = cached; - const { inputToken, outputToken, side, amount } = request; + const { inputToken, outputToken, side, amount, slippagePct } = request; const ethereum = await Ethereum.getInstance(network); @@ -277,6 +274,7 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str expectedAmountOut, side, txHash, + slippagePct, ); // Handle different transaction states @@ -307,37 +305,3 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str } export { executeQuote }; - -export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteQuoteRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-quote', - { - schema: { - description: 'Execute a previously fetched quote from Uniswap Universal Router', - tags: ['/connector/uniswap'], - body: UniswapExecuteQuoteRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { - walletAddress = getEthereumChainConfig().defaultWallet, - network = getEthereumChainConfig().defaultNetwork, - quoteId, - } = request.body as typeof UniswapExecuteQuoteRequest._type; - - return await executeQuote(walletAddress, network, quoteId); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeQuoteRoute; diff --git a/src/connectors/uniswap/router-routes/executeSwap.ts b/src/connectors/uniswap/router-routes/executeSwap.ts index ffd211a698..c8bc86a963 100644 --- a/src/connectors/uniswap/router-routes/executeSwap.ts +++ b/src/connectors/uniswap/router-routes/executeSwap.ts @@ -1,10 +1,7 @@ -import { FastifyPluginAsync } from 'fastify'; - -import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { SwapExecuteResponseType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; // eslint-disable-next-line import/order -import { UniswapExecuteSwapRequest } from '../schemas'; // Import the quote and execute functions import { UniswapConfig } from '../uniswap.config'; @@ -41,42 +38,3 @@ async function executeSwap( } export { executeSwap }; - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: ExecuteSwapRequestType; - Reply: SwapExecuteResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Quote and execute a token swap on Uniswap Universal Router in one step', - tags: ['/connector/uniswap'], - body: UniswapExecuteSwapRequest, - response: { 200: SwapExecuteResponse }, - }, - }, - async (request) => { - try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct } = - request.body as typeof UniswapExecuteSwapRequest._type; - - return await executeSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error executing swap:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/connectors/uniswap/router-routes/index.ts b/src/connectors/uniswap/router-routes/index.ts deleted file mode 100644 index 6b50a2726e..0000000000 --- a/src/connectors/uniswap/router-routes/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; - -import executeQuoteRoute from './executeQuote'; -import executeSwapRoute from './executeSwap'; -import quoteSwapRoute from './quoteSwap'; - -export const uniswapRouterRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(quoteSwapRoute); - await fastify.register(executeQuoteRoute); - await fastify.register(executeSwapRoute); -}; - -export default uniswapRouterRoutes; diff --git a/src/connectors/uniswap/router-routes/quoteSwap.ts b/src/connectors/uniswap/router-routes/quoteSwap.ts index ffa2222c22..b9375d6d76 100644 --- a/src/connectors/uniswap/router-routes/quoteSwap.ts +++ b/src/connectors/uniswap/router-routes/quoteSwap.ts @@ -1,15 +1,12 @@ import { Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { Ethereum } from '../../../chains/ethereum/ethereum'; -import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; -import { QuoteSwapRequestType } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { quoteCache } from '../../../services/quote-cache'; import { sanitizeErrorMessage } from '../../../services/sanitize'; -import { UniswapQuoteSwapRequest, UniswapQuoteSwapResponse } from '../schemas'; +import { UniswapQuoteSwapResponse } from '../schemas'; import { Uniswap } from '../uniswap'; import { UniswapConfig } from '../uniswap.config'; @@ -179,51 +176,3 @@ async function quoteSwap( } export { quoteSwap }; - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - const chainConfig = getEthereumChainConfig(); - - fastify.get<{ - Querystring: QuoteSwapRequestType; - Reply: Static; - }>( - '/quote-swap', - { - schema: { - description: 'Get an executable swap quote from Uniswap Universal Router', - tags: ['/connector/uniswap'], - querystring: UniswapQuoteSwapRequest, - response: { 200: UniswapQuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { - network = chainConfig.defaultNetwork, - walletAddress = chainConfig.defaultWallet, - baseToken, - quoteToken, - amount, - side, - slippagePct, - } = request.query as typeof UniswapQuoteSwapRequest._type; - - return await quoteSwap( - network, - walletAddress, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - ); - } catch (e) { - if (e.statusCode) throw e; - logger.error('Error getting quote:', e); - throw httpErrors.internalServerError(e.message || 'Internal server error'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/connectors/uniswap/schemas.ts b/src/connectors/uniswap/schemas.ts index e17603a95e..ce6619ecc9 100644 --- a/src/connectors/uniswap/schemas.ts +++ b/src/connectors/uniswap/schemas.ts @@ -1,112 +1,22 @@ -import { Type, Static } from '@sinclair/typebox'; - -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; - -import { UniswapConfig } from './uniswap.config'; +import { Type } from '@sinclair/typebox'; // Get chain config for defaults -const ethereumChainConfig = getEthereumChainConfig(); // Constants for examples -const BASE_TOKEN = 'WETH'; -const QUOTE_TOKEN = 'USDC'; -const SWAP_AMOUNT = 0.001; -const AMM_POOL_ADDRESS_EXAMPLE = '0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C'; // Uniswap V2 WETH-USDC pool on Base -const CLMM_POOL_ADDRESS_EXAMPLE = '0xd0b53d9277642d899df5c87a3966a349a798f224'; // Uniswap V3 WETH-USDC pool on Base +// Uniswap V2 WETH-USDC pool on Base // ======================================== // AMM Request Schemas // ======================================== -export const UniswapAmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Uniswap V2 pool address', - examples: [AMM_POOL_ADDRESS_EXAMPLE], - }), -}); - // ======================================== // CLMM Request Schemas // ======================================== -export const UniswapClmmGetPoolInfoRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - poolAddress: Type.String({ - description: 'Uniswap V3 pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - binCount: Type.Optional( - Type.Integer({ - description: - 'If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick), ' + - 'mirroring Meteora pool-info.bins[]. Default 0 — pool-info skips the extra eth_calls.', - default: 0, - minimum: 0, - maximum: 401, - }), - ), -}); -export type UniswapClmmGetPoolInfoRequestType = Static; - // ======================================== // Router Request Schemas // ======================================== -// Uniswap-specific quote-swap request -export const UniswapQuoteSwapRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'First token in the trading pair', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Second token in the trading pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address for more accurate quotes (optional)', - default: ethereumChainConfig.defaultWallet, - }), - ), -}); - // Uniswap-specific quote-swap response export const UniswapQuoteSwapResponse = Type.Object({ quoteId: Type.String({ @@ -142,565 +52,3 @@ export const UniswapQuoteSwapResponse = Type.Object({ }), ), }); - -// Uniswap-specific execute-quote request -export const UniswapExecuteQuoteRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - examples: [ethereumChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - quoteId: Type.String({ - description: 'ID of the quote to execute', - examples: ['123e4567-e89b-12d3-a456-426614174000'], - }), -}); - -// Uniswap AMM Add Liquidity Request -export const UniswapAmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will add liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Address of the Uniswap V2 pool', - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap AMM Create Pool Request -export const UniswapAmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will create and seed the pool', - default: ethereumChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to seed the pool with', - }), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: - 'Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. ' + - 'If omitted (and no initialPrice), the current market price is fetched from the unified swap router.', - }), - ), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the pool is ' + - 'seeded at the current market price so it is not immediately arbitraged.', - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap CLMM Create Pool Request (Uniswap V3) -export const UniswapClmmCreatePoolRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will create and initialize the pool', - default: ethereumChainConfig.defaultWallet, - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address (becomes the pool base)', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'Quote token symbol or address (becomes the pool quote)', - examples: [QUOTE_TOKEN], - }), - fee: Type.Number({ - description: 'Fee tier in hundredths of a bip: 100 (0.01%), 500 (0.05%), 3000 (0.30%), or 10000 (1.00%)', - enum: [100, 500, 3000, 10000], - examples: [3000], - }), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. If omitted, the current market price is fetched from the ' + - 'unified swap router so the pool opens on-market and is not immediately arbitraged.', - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [600000], - }), - ), -}); - -// Uniswap AMM Remove Liquidity Request -export const UniswapAmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will remove liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - poolAddress: Type.String({ - description: 'Address of the Uniswap V2 pool', - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap AMM Execute Swap Request -export const UniswapAmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Pool address (optional - can be looked up from tokens)', - default: '', - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - }), - ), -}); - -// Uniswap-specific execute-swap request -export const UniswapExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - examples: [ethereumChainConfig.defaultWallet], - }), - ), - network: Type.Optional( - Type.String({ - description: 'The blockchain network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - baseToken: Type.String({ - description: 'Token to determine swap direction', - examples: [BASE_TOKEN], - }), - quoteToken: Type.String({ - description: 'The other token in the pair', - examples: [QUOTE_TOKEN], - }), - amount: Type.Number({ - description: 'Amount of base token to trade', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - description: - 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', - enum: ['BUY', 'SELL'], - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - examples: [1], - }), - ), -}); - -// Uniswap CLMM Open Position Request -export const UniswapClmmOpenPositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will open the position', - default: ethereumChainConfig.defaultWallet, - }), - ), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - }), - poolAddress: Type.String({ - description: 'Address of the Uniswap V3 pool', - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap CLMM Add Liquidity Request -export const UniswapClmmAddLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will add liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - baseTokenAmount: Type.Number({ - description: 'Amount of base token to add', - }), - quoteTokenAmount: Type.Number({ - description: 'Amount of quote token to add', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap CLMM Remove Liquidity Request -export const UniswapClmmRemoveLiquidityRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will remove liquidity', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap CLMM Close Position Request -export const UniswapClmmClosePositionRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will close the position', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position to close', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap CLMM Collect Fees Request -export const UniswapClmmCollectFeesRequest = Type.Object({ - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will collect fees', - default: ethereumChainConfig.defaultWallet, - }), - ), - positionAddress: Type.String({ - description: 'NFT token ID of the position', - }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); - -// Uniswap CLMM Execute Swap Request -export const UniswapClmmExecuteSwapRequest = Type.Object({ - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address that will execute the swap', - default: ethereumChainConfig.defaultWallet, - }), - ), - network: Type.Optional( - Type.String({ - description: 'The EVM network to use', - default: ethereumChainConfig.defaultNetwork, - enum: [...UniswapConfig.networks], - }), - ), - poolAddress: Type.Optional( - Type.String({ - description: 'Pool address (optional - can be looked up from tokens)', - }), - ), - baseToken: Type.String({ - description: 'Base token symbol or address', - examples: [BASE_TOKEN], - }), - quoteToken: Type.Optional( - Type.String({ - description: 'Quote token symbol or address', - examples: [QUOTE_TOKEN], - }), - ), - amount: Type.Number({ - description: 'Amount to swap', - examples: [SWAP_AMOUNT], - }), - side: Type.String({ - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: UniswapConfig.config.slippagePct, - }), - ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), -}); diff --git a/src/connectors/uniswap/uniswap.routes.ts b/src/connectors/uniswap/uniswap.routes.ts deleted file mode 100644 index effd187293..0000000000 --- a/src/connectors/uniswap/uniswap.routes.ts +++ /dev/null @@ -1,61 +0,0 @@ -import sensible from '@fastify/sensible'; -import { FastifyPluginAsync } from 'fastify'; - -// Import routes -import { uniswapAmmRoutes } from './amm-routes'; -import { uniswapClmmRoutes } from './clmm-routes'; -import { uniswapRouterRoutes } from './router-routes'; - -// Router routes (Universal Router with 4 endpoints) -const uniswapRouterRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/uniswap']; - } - }); - - await instance.register(uniswapRouterRoutes); - }); -}; - -// AMM routes (Uniswap V2) -const uniswapAmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/uniswap']; - } - }); - - await instance.register(uniswapAmmRoutes); - }); -}; - -// CLMM routes (Uniswap V3) -const uniswapClmmRoutesWrapper: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - - await fastify.register(async (instance) => { - instance.addHook('onRoute', (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.tags) { - routeOptions.schema.tags = ['/connector/uniswap']; - } - }); - - await instance.register(uniswapClmmRoutes); - }); -}; - -// Export routes in the same pattern as other connectors -export const uniswapRoutes = { - router: uniswapRouterRoutesWrapper, - amm: uniswapAmmRoutesWrapper, - clmm: uniswapClmmRoutesWrapper, -}; - -export default uniswapRoutes; diff --git a/src/connectors/uniswap/uniswap.utils.ts b/src/connectors/uniswap/uniswap.utils.ts index eb24b37819..f8b751b0d3 100644 --- a/src/connectors/uniswap/uniswap.utils.ts +++ b/src/connectors/uniswap/uniswap.utils.ts @@ -1,16 +1,15 @@ import { Contract } from '@ethersproject/contracts'; import { Token } from '@uniswap/sdk-core'; -import { Pair as V2Pair } from '@uniswap/v2-sdk'; -import { abi as IUniswapV3PoolABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json'; import { FeeAmount, Pool as V3Pool, SqrtPriceMath, TickMath } from '@uniswap/v3-sdk'; import { FastifyInstance } from 'fastify'; import JSBI from 'jsbi'; -import { TokenInfo, Ethereum } from '../../chains/ethereum/ethereum'; +import { Ethereum } from '../../chains/ethereum/ethereum'; +import { BinLiquidity } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; +import { computeV3BinDistribution, V3SqrtPriceMath, V3TickMath } from '../clmm-v3-utils'; import { Uniswap } from './uniswap'; -import { UniswapConfig } from './uniswap.config'; import { IUniswapV2PairABI } from './uniswap.contracts'; /** @@ -201,7 +200,6 @@ export interface UniswapPoolInfo { export async function getV2PoolInfo(poolAddress: string, network: string): Promise { try { const ethereum = await Ethereum.getInstance(network); - const uniswap = await Uniswap.getInstance(network); // Create pair contract const pairContract = new Contract(poolAddress, IUniswapV2PairABI.abi, ethereum.provider); @@ -304,32 +302,35 @@ export async function getUniswapPoolInfo( /** * Per-bin liquidity distribution around the current tick for a Uniswap V3 pool. - * - * Uniswap V3 has no equivalent of Orca's "fetch all positions for pool" RPC — - * positions are NFTs on the NonfungiblePositionManager. Instead we walk the - * pool's per-tick liquidity profile directly: - * - * 1. Read `liquidityNet` at every bin boundary in the window via parallel - * `pool.ticks(tick)` reads (one eth_call each, fired with Promise.all). - * Ticks that have never been initialized return zeros — that's harmless. - * 2. Start with the pool's active L = pool.liquidity() in the bin that - * contains the current tick. Propagate L outward by adding/subtracting - * `liquidityNet` at each boundary crossed (per the V3 spec). - * 3. For each bin, convert L → (amount0, amount1) via - * SqrtPriceMath.getAmount{0,1}Delta(sqrtA, sqrtB, L, false), splitting - * at the pool's current sqrtPriceX96 when the bin straddles the - * active tick. - * 4. Map to base/quote using `isBaseToken0`, scale by decimals. - * - * Output shape mirrors Meteora's `pool-info.bins[]`: - * { binId, price, baseTokenAmount, quoteTokenAmount } + * The tick walk lives in the shared V3 helper (`clmm-v3-utils`); Uniswap's SDK is + * JSBI-based while the helper works in native bigint, so the math is adapted here. */ -export interface UniswapBinDistributionEntry { - binId: number; - price: number; - baseTokenAmount: number; - quoteTokenAmount: number; -} +export type UniswapBinDistributionEntry = BinLiquidity; + +const uniswapTickMath: V3TickMath = { + getSqrtRatioAtTick: (tick) => BigInt(TickMath.getSqrtRatioAtTick(tick).toString()), +}; + +const uniswapSqrtPriceMath: V3SqrtPriceMath = { + getAmount0Delta: (a, b, l, roundUp) => + BigInt( + SqrtPriceMath.getAmount0Delta( + JSBI.BigInt(a.toString()), + JSBI.BigInt(b.toString()), + JSBI.BigInt(l.toString()), + roundUp, + ).toString(), + ), + getAmount1Delta: (a, b, l, roundUp) => + BigInt( + SqrtPriceMath.getAmount1Delta( + JSBI.BigInt(a.toString()), + JSBI.BigInt(b.toString()), + JSBI.BigInt(l.toString()), + roundUp, + ).toString(), + ), +}; export async function computeUniswapBinDistribution(args: { poolContract: Contract; @@ -342,84 +343,11 @@ export async function computeUniswapBinDistribution(args: { isBaseToken0: boolean; binCount: number; }): Promise { - const { - poolContract, - tickSpacing, - currentTick, - currentSqrtPriceX96, - activeLiquidity, - decimals0, - decimals1, - isBaseToken0, - binCount, - } = args; - if (binCount <= 0) return []; - - const halfBins = Math.floor(binCount / 2); - const snapped = Math.floor(currentTick / tickSpacing) * tickSpacing; - const firstBinStart = snapped - halfBins * tickSpacing; - const boundaries: number[] = []; - for (let i = 0; i <= binCount; i++) { - boundaries.push(firstBinStart + i * tickSpacing); - } - - // Parallel reads of pool.ticks(tick) at each boundary. Non-initialized - // ticks return zeros which is the correct neutral element for liquidityNet. - const tickData = await Promise.all( - boundaries.map((tick) => poolContract.ticks(tick).catch(() => ({ liquidityNet: 0 }))), - ); - - const curIdx = Math.floor((currentTick - firstBinStart) / tickSpacing); - - // Propagate L outward from the current bin (V3 spec: crossing a tick going - // UP adds liquidityNet, going DOWN subtracts it). - const binLs: JSBI[] = new Array(binCount); - binLs[curIdx] = activeLiquidity; - for (let i = curIdx + 1; i < binCount; i++) { - const net = JSBI.BigInt(tickData[i].liquidityNet.toString()); - binLs[i] = JSBI.add(binLs[i - 1], net); - } - for (let i = curIdx - 1; i >= 0; i--) { - const net = JSBI.BigInt(tickData[i + 1].liquidityNet.toString()); - binLs[i] = JSBI.subtract(binLs[i + 1], net); - } - - const scale0 = Math.pow(10, decimals0); - const scale1 = Math.pow(10, decimals1); - const zero = JSBI.BigInt(0); - const bins: UniswapBinDistributionEntry[] = []; - for (let i = 0; i < binCount; i++) { - const tickStart = boundaries[i]; - const tickEnd = boundaries[i + 1]; - const L = binLs[i]; - let amount0: JSBI = zero; - let amount1: JSBI = zero; - if (JSBI.greaterThan(L, zero)) { - const sqrtA = TickMath.getSqrtRatioAtTick(tickStart); - const sqrtB = TickMath.getSqrtRatioAtTick(tickEnd); - if (currentTick >= tickEnd) { - amount1 = SqrtPriceMath.getAmount1Delta(sqrtA, sqrtB, L, false); - } else if (currentTick < tickStart) { - amount0 = SqrtPriceMath.getAmount0Delta(sqrtA, sqrtB, L, false); - } else { - amount0 = SqrtPriceMath.getAmount0Delta(currentSqrtPriceX96, sqrtB, L, false); - amount1 = SqrtPriceMath.getAmount1Delta(sqrtA, currentSqrtPriceX96, L, false); - } - } - const amt0 = parseFloat(amount0.toString()) / scale0; - const amt1 = parseFloat(amount1.toString()) / scale1; - const baseTokenAmount = isBaseToken0 ? amt0 : amt1; - const quoteTokenAmount = isBaseToken0 ? amt1 : amt0; - - // Price at tickStart in human units (quote/base regardless of token order). - const rawT1PerT0 = Math.pow(1.0001, tickStart) * Math.pow(10, decimals0 - decimals1); - const price = isBaseToken0 ? rawT1PerT0 : 1 / rawT1PerT0; - bins.push({ - binId: tickStart, - price, - baseTokenAmount, - quoteTokenAmount, - }); - } - return bins; + return computeV3BinDistribution({ + ...args, + currentSqrtPriceX96: BigInt(args.currentSqrtPriceX96.toString()), + activeLiquidity: BigInt(args.activeLiquidity.toString()), + tickMath: uniswapTickMath, + sqrtPriceMath: uniswapSqrtPriceMath, + }); } diff --git a/src/connectors/uniswap/uniswap_v2_router_abi.json b/src/connectors/uniswap/uniswap_v2_router_abi.json index c5f373418f..1db522ac19 100644 --- a/src/connectors/uniswap/uniswap_v2_router_abi.json +++ b/src/connectors/uniswap/uniswap_v2_router_abi.json @@ -20,4 +20,4 @@ "type": "function" } ] -} \ No newline at end of file +} diff --git a/src/paths.ts b/src/paths.ts index 90f962d403..1231cd9793 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,6 +1,3 @@ -import fs from 'fs'; -import path from 'path'; - /** * Returns the project root path. * @@ -9,8 +6,5 @@ import path from 'path'; * via ts-jest. */ export function rootPath(): string { - const insideDistDir: boolean = __filename.match(/dist\//) !== null; - // Return absolute path to project root, always pointing to /Users/feng/gateway - // regardless of environment return process.cwd(); } diff --git a/src/pools/pool-info-helpers.ts b/src/pools/pool-info-helpers.ts index 89a3d6088a..7ad2158b60 100644 --- a/src/pools/pool-info-helpers.ts +++ b/src/pools/pool-info-helpers.ts @@ -2,13 +2,9 @@ * Helper functions for fetching pool info from connectors */ -import { FastifyInstance } from 'fastify'; - import { Ethereum } from '../chains/ethereum/ethereum'; import { Solana } from '../chains/solana/solana'; import { connectorsConfig } from '../config/routes/getConnectors'; -import { PoolInfo as AmmPoolInfo } from '../schemas/amm-schema'; -import { PoolInfo as ClmmPoolInfo } from '../schemas/clmm-schema'; import { logger } from '../services/logger'; interface PoolInfoResult { @@ -109,31 +105,6 @@ export async function fetchPoolInfo( const fee = await poolContract.fee(); feePct = fee / 10000; // Convert from basis points to percentage } else { - // V2 pools - get fee from factory contract - const v2PairABI = [ - { - inputs: [], - name: 'factory', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - ]; - - const v2FactoryABI = [ - { - inputs: [], - name: 'feeTo', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - ]; - - const pairContract = new Contract(poolAddress, v2PairABI, ethereum.provider); - const factoryAddress = await pairContract.factory(); - const factoryContract = new Contract(factoryAddress, v2FactoryABI, ethereum.provider); - // V2 pairs typically have 0.3% fee (30 basis points) // PancakeSwap V2 has 0.25% fee (25 basis points) // Since the fee isn't exposed on-chain for V2, we use the standard for each DEX diff --git a/src/pools/routes/addPool.ts b/src/pools/routes/addPool.ts index 4b8a7b5bb1..7d56526748 100644 --- a/src/pools/routes/addPool.ts +++ b/src/pools/routes/addPool.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { PoolService } from '../../services/pool-service'; import { fetchPoolInfo, resolveTokenSymbols } from '../pool-info-helpers'; import { PoolAddRequestSchema, PoolSuccessResponseSchema } from '../schemas'; @@ -15,21 +16,14 @@ export const addPoolRoute: FastifyPluginAsync = async (fastify) => { body: PoolAddRequestSchema, response: { 200: PoolSuccessResponseSchema, - 400: { - type: 'object', - properties: { - message: { type: 'string' }, - }, - }, }, }, }, async (request) => { const { - chain, + chainNetwork, connector, type, - network, address, baseSymbol, quoteSymbol, @@ -37,6 +31,7 @@ export const addPoolRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAddress, feePct, } = request.body; + const { chain, network } = parseChainNetwork(chainNetwork); const poolService = PoolService.getInstance(); diff --git a/src/pools/routes/findPools.ts b/src/pools/routes/findPools.ts index 515c5a900b..c87ed4c7f8 100644 --- a/src/pools/routes/findPools.ts +++ b/src/pools/routes/findPools.ts @@ -1,6 +1,7 @@ import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { TopPoolInfo } from '../../services/coingecko-service'; import { handlePoolError } from '../pool-error-handler'; import { findPools } from '../pool-finder'; @@ -105,9 +106,10 @@ export const findPoolsRoute: FastifyPluginAsync = async (fastify) => { page: pages, }); - // Transform TopPoolInfo to PoolInfo format - // Extract network from chainNetwork (format: chain-network) - const network = chainNetwork.split('-').slice(1).join('-'); + // Transform TopPoolInfo to PoolInfo format. + // Through the shared parser rather than an inline split: the hand-rolled version + // answered '' for a selector with no hyphen and stamped that onto every pool. + const { network } = parseChainNetwork(chainNetwork); const pools = topPools.map((topPool) => { const pool = transformToPoolInfo(topPool); diff --git a/src/pools/routes/getPool.ts b/src/pools/routes/getPool.ts index 703cdb51cc..01954b5b30 100644 --- a/src/pools/routes/getPool.ts +++ b/src/pools/routes/getPool.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { PoolService } from '../../services/pool-service'; import { GetPoolRequestSchema, PoolListResponseSchema } from '../schemas'; @@ -7,8 +8,7 @@ export const getPoolRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Params: { tradingPair: string }; Querystring: { - chain: string; - network: string; + chainNetwork: string; type: string; connector?: string; }; @@ -29,30 +29,16 @@ export const getPoolRoute: FastifyPluginAsync = async (fastify) => { }, required: ['tradingPair'], }, - querystring: { - ...GetPoolRequestSchema, - properties: { - ...GetPoolRequestSchema.properties, - network: { - ...GetPoolRequestSchema.properties.network, - default: 'mainnet-beta', - }, - }, - }, + querystring: GetPoolRequestSchema, response: { 200: PoolListResponseSchema.items, - 404: { - type: 'object', - properties: { - message: { type: 'string' }, - }, - }, }, }, }, async (request) => { const { tradingPair } = request.params; - const { chain, network, type, connector } = request.query; + const { chainNetwork, type, connector } = request.query; + const { chain, network } = parseChainNetwork(chainNetwork); const poolService = PoolService.getInstance(); try { diff --git a/src/pools/routes/listPools.ts b/src/pools/routes/listPools.ts index c7185cf19f..9a8e7666e8 100644 --- a/src/pools/routes/listPools.ts +++ b/src/pools/routes/listPools.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { PoolService } from '../../services/pool-service'; import { PoolListRequestSchema, PoolListResponseSchema } from '../schemas'; import { PoolListRequest } from '../types'; @@ -18,7 +19,8 @@ export const listPoolsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request) => { - const { chain, network, connector, type, search } = request.query; + const { chainNetwork, connector, type, search } = request.query; + const { chain, network } = parseChainNetwork(chainNetwork); const poolService = PoolService.getInstance(); try { diff --git a/src/pools/routes/removePool.ts b/src/pools/routes/removePool.ts index bb94e9c91f..79b36ce4c0 100644 --- a/src/pools/routes/removePool.ts +++ b/src/pools/routes/removePool.ts @@ -1,6 +1,8 @@ import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; +import { chainNetworkField } from '../../schemas/chain-network-field'; +import { parseChainNetwork } from '../../services/chain-network'; import { PoolService } from '../../services/pool-service'; import { PoolSuccessResponseSchema } from '../schemas'; @@ -8,8 +10,7 @@ export const removePoolRoute: FastifyPluginAsync = async (fastify) => { fastify.delete<{ Params: { address: string }; Querystring: { - chain: string; - network: string; + chainNetwork: string; }; }>( '/:address', @@ -27,30 +28,16 @@ export const removePoolRoute: FastifyPluginAsync = async (fastify) => { }, required: ['address'], }, - querystring: Type.Object({ - chain: Type.String({ - description: 'Blockchain chain (solana, ethereum)', - examples: ['solana', 'ethereum'], - }), - network: Type.String({ - description: 'Network name (mainnet, mainnet-beta, etc)', - examples: ['mainnet', 'mainnet-beta'], - }), - }), + querystring: Type.Object({ chainNetwork: chainNetworkField({ defaulted: false }) }), response: { 200: PoolSuccessResponseSchema, - 404: { - type: 'object', - properties: { - message: { type: 'string' }, - }, - }, }, }, }, async (request) => { const { address } = request.params; - const { chain, network } = request.query; + const { chainNetwork } = request.query; + const { chain, network } = parseChainNetwork(chainNetwork); const poolService = PoolService.getInstance(); try { diff --git a/src/pools/schemas.ts b/src/pools/schemas.ts index 37895c7ed5..fa56e84a91 100644 --- a/src/pools/schemas.ts +++ b/src/pools/schemas.ts @@ -1,17 +1,11 @@ import { Type } from '@sinclair/typebox'; -import { ConfigManagerV2 } from '../services/config-manager-v2'; +import { chainNetworkField } from '../schemas/chain-network-field'; +import { DecimalNumber } from '../schemas/decimal-field'; // Pool list request export const PoolListRequestSchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain chain (solana, ethereum)', - examples: ['solana', 'ethereum'], - }), - network: Type.String({ - description: 'Network name (mainnet-beta, mainnet, base, etc)', - examples: ['mainnet-beta', 'mainnet', 'base', 'arbitrum'], - }), + chainNetwork: chainNetworkField({ defaulted: false }), connector: Type.Optional( Type.String({ description: 'Optional: filter by connector (raydium, meteora, uniswap, orca)', @@ -48,7 +42,7 @@ export const PoolTemplateSchema = Type.Object({ quoteSymbol: Type.String(), baseTokenAddress: Type.String(), quoteTokenAddress: Type.String(), - feePct: Type.Number(), + feePct: DecimalNumber({}), address: Type.String(), }); @@ -59,10 +53,7 @@ export const PoolListResponseSchema = Type.Array(PoolTemplateSchema); // Add pool request export const PoolAddRequestSchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain chain (solana, ethereum)', - examples: ['solana', 'ethereum'], - }), + chainNetwork: chainNetworkField({ defaulted: false }), connector: Type.String({ description: 'Connector (raydium, meteora, uniswap, orca)', examples: ['raydium', 'meteora', 'uniswap', 'orca'], @@ -72,11 +63,6 @@ export const PoolAddRequestSchema = Type.Object({ examples: ['clmm', 'amm'], enum: ['clmm', 'amm'], }), - network: Type.String({ - description: 'Network name (mainnet, mainnet-beta, etc)', - examples: ['mainnet-beta', 'mainnet'], - default: 'mainnet-beta', - }), address: Type.String({ description: 'Pool contract address', }), @@ -102,6 +88,7 @@ export const PoolAddRequestSchema = Type.Object({ }), feePct: Type.Optional( Type.Number({ + format: 'decimal', description: 'Pool fee percentage (optional - fetched from pool-info if not provided)', examples: [0.25, 0.3, 1], minimum: 0, @@ -112,15 +99,7 @@ export const PoolAddRequestSchema = Type.Object({ // Get pool request export const GetPoolRequestSchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain chain (solana, ethereum)', - examples: ['solana', 'ethereum'], - }), - network: Type.String({ - description: 'Network name (mainnet, mainnet-beta, etc)', - examples: ['mainnet-beta', 'mainnet'], - default: 'mainnet-beta', - }), + chainNetwork: chainNetworkField({ defaulted: false }), type: Type.String({ description: 'Pool type', examples: ['amm', 'clmm'], diff --git a/src/pools/types.ts b/src/pools/types.ts index 423ea7d385..d7774f5ef8 100644 --- a/src/pools/types.ts +++ b/src/pools/types.ts @@ -36,18 +36,16 @@ export function isSupportedConnector(connector: string): boolean { } export interface PoolListRequest { - chain: string; - network: string; + chainNetwork: string; connector?: string; // Optional filter by connector type?: 'amm' | 'clmm'; search?: string; } export interface PoolAddRequest { - chain: string; + chainNetwork: string; connector: string; type: 'amm' | 'clmm'; - network: string; address: string; baseSymbol: string; // Required quoteSymbol: string; // Required diff --git a/src/rpc/helius-service.ts b/src/rpc/helius-service.ts index 4f213ee5ed..dca84fb7de 100644 --- a/src/rpc/helius-service.ts +++ b/src/rpc/helius-service.ts @@ -136,7 +136,7 @@ export class HeliusService extends RPCProvider { public disconnect(): void { this.cancelIdleTimeout(); - for (const [_, subscription] of this.subscriptions) { + for (const subscription of this.subscriptions.values()) { clearTimeout(subscription.timeout); subscription.reject(new Error('Service disconnected')); } @@ -267,7 +267,7 @@ export class HeliusService extends RPCProvider { } private handleWebSocketClose(): void { - for (const [_, subscription] of this.subscriptions) { + for (const subscription of this.subscriptions.values()) { clearTimeout(subscription.timeout); subscription.reject(new Error('WebSocket disconnected')); } diff --git a/src/schemas/amm-schema.ts b/src/schemas/amm-schema.ts index a9b678ff90..3ea28214a9 100644 --- a/src/schemas/amm-schema.ts +++ b/src/schemas/amm-schema.ts @@ -1,18 +1,18 @@ import { Type, Static } from '@sinclair/typebox'; -import { TransactionStatus } from './chain-schema'; +import { DecimalNumber } from './decimal-field'; export const PoolInfoSchema = Type.Object( { address: Type.String(), baseTokenAddress: Type.String(), quoteTokenAddress: Type.String(), - feePct: Type.Number(), - price: Type.Number(), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), + feePct: DecimalNumber({}), + price: DecimalNumber({}), + baseTokenAmount: DecimalNumber({}), + quoteTokenAmount: DecimalNumber({}), }, - { $id: 'PoolInfo' }, + { $id: 'AmmPoolInfo' }, ); export type PoolInfo = Static; @@ -21,7 +21,9 @@ export const GetPoolInfoRequest = Type.Object( network: Type.Optional(Type.String()), poolAddress: Type.String(), }, - { $id: 'GetPoolInfoRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type GetPoolInfoRequestType = Static; @@ -30,11 +32,19 @@ export const AddLiquidityRequest = Type.Object( network: Type.Optional(Type.String()), walletAddress: Type.Optional(Type.String()), poolAddress: Type.String(), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + baseTokenAmount: Type.Number({ format: 'decimal' }), + quoteTokenAmount: Type.Number({ format: 'decimal' }), + slippagePct: Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), + ), }, - { $id: 'AddLiquidityRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type AddLiquidityRequestType = Static; @@ -45,31 +55,61 @@ export const AddLiquidityResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - baseTokenAmountAdded: Type.Number(), - quoteTokenAmountAdded: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + // The venue this write touched. Echoed so a stored record identifies its pool + // without the request that produced it — the same reason the swap execute + // responses carry it. + poolAddress: Type.Optional(Type.String({ description: 'Pool this operation acted on' })), + // Always the position the write touched — the one just opened when no address was + // given, or the one named. Without it a caller who just paid to open a DAMM v2 + // position could only recover its address by re-listing positions-owned and + // diffing, which races any concurrent write and cannot attribute an address to a + // transaction. + positionAddress: Type.Optional( + Type.String({ + description: + 'Position the liquidity went into. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.', + 'x-connectors': ['meteora'], + } as any), + ), + positionRent: Type.Optional( + DecimalNumber({ + description: + 'Native token locked as rent when this call opened the position. Absent when adding to a position that already existed, and on fungible-LP AMMs.', + 'x-connectors': ['meteora'], + } as any), + ), + baseTokenAmountAdded: DecimalNumber({}), + quoteTokenAmountAdded: DecimalNumber({}), + }, + { $id: 'AmmAddLiquidityResponseData' }, + ), ), }, - { $id: 'AddLiquidityResponse' }, + { $id: 'AmmAddLiquidityResponse' }, ); export type AddLiquidityResponseType = Static; -export const QuoteLiquidityRequest = Type.Omit(AddLiquidityRequest, ['walletAddress'], { - $id: 'QuoteLiquidityRequest', -}); +// No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as +// the base a unified route composes from. Publishing it would generate a client that +// sends the wrong keys under a name the real wire shape wants. +export const QuoteLiquidityRequest = Type.Omit(AddLiquidityRequest, ['walletAddress']); export type QuoteLiquidityRequestType = Static; export const QuoteLiquidityResponse = Type.Object( { + // The pool this split was computed against — on CLMM the caller need not have + // named one, and on AMM it keeps the quote self-describing alongside quote-swap. + poolAddress: Type.Optional(Type.String({ description: 'Pool the quote was computed against' })), baseLimited: Type.Boolean(), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - baseTokenAmountMax: Type.Number(), - quoteTokenAmountMax: Type.Number(), + baseTokenAmount: DecimalNumber({}), + quoteTokenAmount: DecimalNumber({}), + baseTokenAmountMax: DecimalNumber({}), + quoteTokenAmountMax: DecimalNumber({}), }, - { $id: 'QuoteLiquidityResponse' }, + { $id: 'AmmQuoteLiquidityResponse' }, ); export type QuoteLiquidityResponseType = Static; @@ -78,9 +118,15 @@ export const RemoveLiquidityRequest = Type.Object( network: Type.Optional(Type.String()), walletAddress: Type.Optional(Type.String()), poolAddress: Type.String(), - percentageToRemove: Type.Number({ minimum: 0, maximum: 100 }), + percentageToRemove: Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), }, - { $id: 'RemoveLiquidityRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type RemoveLiquidityRequestType = Static; @@ -91,14 +137,38 @@ export const RemoveLiquidityResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - baseTokenAmountRemoved: Type.Number(), - quoteTokenAmountRemoved: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + // The venue this write touched. Echoed so a stored record identifies its pool + // without the request that produced it — the same reason the swap execute + // responses carry it. + poolAddress: Type.Optional(Type.String({ description: 'Pool this operation acted on' })), + // Only AMMs whose positions are discrete accounts have one to name; a + // fungible-LP AMM holds liquidity as LP tokens against the pool. + positionAddress: Type.Optional( + Type.String({ description: 'Position this operation acted on', 'x-connectors': ['meteora'] } as any), + ), + // Present only when the removal closed the position account, which is what + // removing 100% does: the account is closed in the same transaction and its + // rent comes back. A partial removal leaves the account open and refunds + // nothing, and fungible-LP AMMs have no account to close, so both omit it + // rather than reporting a 0 that would read as "closed, refunded nothing". + positionRentRefunded: Type.Optional( + DecimalNumber({ + description: + 'Native token rent returned when the position account closed. Present only on a 100% removal from an AMM whose positions are accounts.', + 'x-connectors': ['meteora'], + } as any), + ), + baseTokenAmountRemoved: DecimalNumber({}), + quoteTokenAmountRemoved: DecimalNumber({}), + }, + { $id: 'AmmRemoveLiquidityResponseData' }, + ), ), }, - { $id: 'RemoveLiquidityResponse' }, + { $id: 'AmmRemoveLiquidityResponse' }, ); export type RemoveLiquidityResponseType = Static; @@ -112,9 +182,13 @@ export const CreatePoolRequest = Type.Object( walletAddress: Type.Optional(Type.String()), baseToken: Type.String({ description: 'Base token symbol or address (becomes the pool base)' }), quoteToken: Type.String({ description: 'Quote token symbol or address (becomes the pool quote)' }), - baseTokenAmount: Type.Number({ description: 'Amount of base token to seed the pool with' }), + baseTokenAmount: Type.Number({ + format: 'decimal', + description: 'Amount of base token to seed the pool with', + }), quoteTokenAmount: Type.Optional( Type.Number({ + format: 'decimal', description: 'Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. ' + 'If omitted (and no initialPrice), the price is fetched from the market.', @@ -122,13 +196,16 @@ export const CreatePoolRequest = Type.Object( ), initialPrice: Type.Optional( Type.Number({ + format: 'decimal', description: 'Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current ' + 'market price is fetched from the unified swap router so the pool opens on-market.', }), ), }, - { $id: 'CreatePoolRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type CreatePoolRequestType = Static; @@ -137,18 +214,25 @@ export const CreatePoolResponse = Type.Object( signature: Type.String(), status: Type.Number({ description: 'TransactionStatus enum value' }), poolAddress: Type.String({ description: 'Address of the newly created pool' }), - price: Type.Optional(Type.Number({ description: 'Initial price the pool was seeded at (quote per base)' })), + price: Type.Optional( + DecimalNumber({ + description: 'Initial price the pool was seeded at (quote per base)', + }), + ), // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - baseTokenAmountAdded: Type.Number(), - quoteTokenAmountAdded: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + baseTokenAmountAdded: DecimalNumber({}), + quoteTokenAmountAdded: DecimalNumber({}), + }, + { $id: 'AmmCreatePoolResponseData' }, + ), ), }, - { $id: 'CreatePoolResponse' }, + { $id: 'AmmCreatePoolResponse' }, ); export type CreatePoolResponseType = Static; @@ -158,9 +242,11 @@ export type CreatePoolResponseType = Static; export const PositionDetailSchema = Type.Object( { positionAddress: Type.String({ description: 'Address of the individual position (NFT position account)' }), - lpTokenAmount: Type.Number({ description: 'Liquidity held by this position (LP units)' }), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), + lpTokenAmount: DecimalNumber({ + description: 'Liquidity held by this position (LP units)', + }), + baseTokenAmount: DecimalNumber({}), + quoteTokenAmount: DecimalNumber({}), }, { $id: 'PositionDetail' }, ); @@ -172,16 +258,16 @@ export const PositionInfoSchema = Type.Object( walletAddress: Type.String(), baseTokenAddress: Type.String(), quoteTokenAddress: Type.String(), - lpTokenAmount: Type.Number(), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - price: Type.Number(), + lpTokenAmount: DecimalNumber({}), + baseTokenAmount: DecimalNumber({}), + quoteTokenAmount: DecimalNumber({}), + price: DecimalNumber({}), // Per-position breakdown for non-fungible-LP AMMs. When a wallet holds multiple positions in a // pool, the top-level amounts are the aggregate and each entry here is individually addressable // (pass its positionAddress to remove-liquidity / add-liquidity). Omitted for fungible-LP AMMs. positions: Type.Optional(Type.Array(PositionDetailSchema)), }, - { $id: 'PositionInfo' }, + { $id: 'AmmPositionInfo' }, ); export type PositionInfo = Static; @@ -191,7 +277,9 @@ export const GetPositionInfoRequest = Type.Object( poolAddress: Type.String(), walletAddress: Type.Optional(Type.String()), }, - { $id: 'GetPositionInfoRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type GetPositionInfoRequestType = Static; @@ -215,14 +303,22 @@ export const QuoteSwapRequest = Type.Object( description: 'The other token in the pair (optional - required if poolAddress not provided)', }), ), - amount: Type.Number(), + amount: Type.Number({ format: 'decimal' }), side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'], }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + slippagePct: Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), + ), }, - { $id: 'AmmQuoteSwapRequest' }, + // No $id: this is the pre-refactor shape (per-connector `network`, no `connector`), + // kept only as the base the unified route composes from. The request actually on the + // wire is the route's own querystring, which now carries this name as its $id. ); export type QuoteSwapRequestType = Static; @@ -231,15 +327,17 @@ export const QuoteSwapResponse = Type.Object( poolAddress: Type.String(), tokenIn: Type.String(), tokenOut: Type.String(), - amountIn: Type.Number(), - amountOut: Type.Number(), - price: Type.Number(), - slippagePct: Type.Optional(Type.Number()), - minAmountOut: Type.Number(), - maxAmountIn: Type.Number(), - priceImpactPct: Type.Number(), + amountIn: DecimalNumber({}), + amountOut: DecimalNumber({}), + price: DecimalNumber({}), + slippagePct: Type.Optional(DecimalNumber({})), + minAmountOut: DecimalNumber({}), + maxAmountIn: DecimalNumber({}), + priceImpactPct: DecimalNumber({}), }, - { $id: 'AmmQuoteSwapResponse' }, + // No $id: no route serves this shape. The pool-scoped surfaces answer with the shared + // Chain* responses, so publishing this would put a name a caller reaches for on a + // shape they never receive. Kept as the base those responses compose from. ); export type QuoteSwapResponseType = Static; @@ -258,13 +356,23 @@ export const ExecuteSwapRequest = Type.Object( description: 'The other token in the pair (optional - required if poolAddress not provided)', }), ), - amount: Type.Number(), + amount: Type.Number({ format: 'decimal' }), side: Type.String({ enum: ['BUY', 'SELL'], }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + slippagePct: Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), + ), }, - { $id: 'AmmExecuteSwapRequest' }, + // No $id: this is the pre-refactor shape (per-connector `network`, no `connector`), + // kept only as the base the unified route composes from. The request actually on the + // wire is the route's own schema, which now carries this name as its $id — publishing + // both would collide, and publishing this one would generate a client that sends the + // wrong keys. ); export type ExecuteSwapRequestType = Static; @@ -275,17 +383,28 @@ export const ExecuteSwapResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - tokenIn: Type.String(), - tokenOut: Type.String(), - amountIn: Type.Number(), - amountOut: Type.Number(), - fee: Type.Number(), - baseTokenBalanceChange: Type.Number(), - quoteTokenBalanceChange: Type.Number(), - }), + Type.Object( + { + tokenIn: Type.String(), + tokenOut: Type.String(), + amountIn: DecimalNumber({}), + amountOut: DecimalNumber({}), + fee: DecimalNumber({}), + baseTokenBalanceChange: DecimalNumber({}), + quoteTokenBalanceChange: DecimalNumber({}), + slippagePct: Type.Optional( + DecimalNumber({ + description: 'Slippage tolerance percentage actually applied to the swap', + }), + ), + }, + // No $id: its parent is not published either — nothing would reference this, and a + // generated client would carry it as a class no response ever produces. + ), ), }, - { $id: 'AmmExecuteSwapResponse' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type ExecuteSwapResponseType = Static; diff --git a/src/schemas/chain-network-field.ts b/src/schemas/chain-network-field.ts new file mode 100644 index 0000000000..d1ded0d34f --- /dev/null +++ b/src/schemas/chain-network-field.ts @@ -0,0 +1,51 @@ +import { Type } from '@sinclair/typebox'; + +import { ConfigManagerV2 } from '../services/config-manager-v2'; + +/** + * Every chain-network Gateway is configured for, read from its config namespaces. + * + * This is the enum on `chainNetworkField`, so an unconfigured or malformed selector is + * rejected by the schema rather than split into parts and half-used. Read at load rather + * than listed, so adding a network's config is the only step. + */ +export const SUPPORTED_CHAIN_NETWORKS = ConfigManagerV2.getInstance().getSupportedChainNetworks(); + +export const DEFAULT_CHAIN_NETWORK = 'solana-mainnet-beta'; + +if (!SUPPORTED_CHAIN_NETWORKS.includes(DEFAULT_CHAIN_NETWORK)) { + // Fastify injects a schema default before the handler runs, so a default outside the + // enum would make every request that omits chainNetwork fail its own validation. + throw new Error( + `Routes default chainNetwork to '${DEFAULT_CHAIN_NETWORK}', which is not among the configured ` + + `chain-networks: ${SUPPORTED_CHAIN_NETWORKS.join(', ') || '(none)'}. ` + + 'Restore that namespace under conf/, or change the default.', + ); +} + +/** + * The chain-network selector, for every route that is not already addressed by a `chain` + * path parameter. + * + * One field so one convention: `/pools` and `/tokens` used to take `chain` and `network` + * separately on some routes and `chainNetwork` on others — the same resource addressed two + * ways inside a single router, which a caller had to learn route by route. + */ +export const chainNetworkField = ({ defaulted = true, description }: ChainNetworkFieldOptions = {}) => + Type.String({ + description: + description ?? 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', + enum: SUPPORTED_CHAIN_NETWORKS, + // A default is a convenience on a route that reads, and a hazard on one that writes: + // Fastify injects it before the handler runs, so `DELETE /pools/{address}` with no + // chainNetwork would pick a network and delete from it. The data-management routes + // ask for it rather than guess. + ...(defaulted ? { default: DEFAULT_CHAIN_NETWORK } : {}), + examples: [DEFAULT_CHAIN_NETWORK], + }); + +export interface ChainNetworkFieldOptions { + /** Whether omitting it means the default chain-network. False where guessing is unsafe. */ + defaulted?: boolean; + description?: string; +} diff --git a/src/schemas/chain-schema.ts b/src/schemas/chain-schema.ts index 6e7f49d896..b5aff820d4 100644 --- a/src/schemas/chain-schema.ts +++ b/src/schemas/chain-schema.ts @@ -1,5 +1,40 @@ import { Type, Static } from '@sinclair/typebox'; +import { networks as ethereumNetworks } from '../chains/ethereum/ethereum.config'; +import { networks as solanaNetworks } from '../chains/solana/solana.config'; + +import { DecimalNumber } from './decimal-field'; + +/** + * Every network the chain routes accept, read from the chain configs rather than + * listed here, so a network added to conf/chains appears in the docs without an + * edit. The union spans both chains: `chain` is a path parameter and `network` a + * query/body field, and OpenAPI cannot make one enum depend on another parameter. + * Passing a network belonging to the other chain still fails, in resolveChain, + * with a message naming the chain's own networks. + */ +export const CHAIN_NETWORKS = [...new Set([...solanaNetworks, ...ethereumNetworks])]; + +/** + * Network selector shared by every chain route. + * + * Carries `examples` rather than `default` on purpose. Fastify injects schema + * defaults into the request before the handler runs, so a default of + * 'mainnet-beta' here would be injected for /chains/ethereum/* too and turn a + * working call that omits the network into "Network 'mainnet-beta' is not an + * ethereum network". Left absent, each chain resolves its own configured default + * (solana mainnet-beta, ethereum mainnet) as it does today, while Swagger still + * renders the enum as a dropdown and shows the example. + */ +export const networkField = () => + Type.Optional( + Type.String({ + description: "Network to use. Defaults to the chain's configured default network.", + enum: CHAIN_NETWORKS, + examples: ['mainnet-beta'], + }), + ); + // Transaction status enum export enum TransactionStatus { PENDING = 0, @@ -9,26 +44,26 @@ export enum TransactionStatus { export const EstimateGasRequestSchema = Type.Object( { - network: Type.Optional(Type.String()), + network: networkField(), }, - { $id: 'EstimateGasRequest' }, + { $id: 'EstimateGasRequest', additionalProperties: false }, ); export type EstimateGasRequestType = Static; export const EstimateGasResponseSchema = Type.Object( { - feePerComputeUnit: Type.Number(), // Fee per compute unit (legacy gas price or maxFeePerGas for EIP-1559) + feePerComputeUnit: DecimalNumber({}), // Fee per compute unit (legacy gas price or maxFeePerGas for EIP-1559) denomination: Type.String(), // Denomination: "lamports" or "gwei" computeUnits: Type.Number(), // Default compute units/gas limit used for fee calculation feeAsset: Type.String(), // Native currency symbol from network config (ETH, SOL, etc.) - fee: Type.Number(), // Total fee calculated using default gas/compute limits + fee: DecimalNumber({}), // Total fee calculated using default gas/compute limits timestamp: Type.Number(), // Unix timestamp when estimate was made gasType: Type.Optional(Type.String()), // Gas type: "legacy" or "eip1559" - maxFeePerGas: Type.Optional(Type.Number()), // EIP-1559: Maximum fee per gas in gwei - maxPriorityFeePerGas: Type.Optional(Type.Number()), // EIP-1559: Maximum priority fee per gas in gwei + maxFeePerGas: Type.Optional(DecimalNumber({})), // EIP-1559: Maximum fee per gas in gwei + maxPriorityFeePerGas: Type.Optional(DecimalNumber({})), // EIP-1559: Maximum priority fee per gas in gwei // Solana Helius-specific fields priorityFeeLevel: Type.Optional(Type.String()), // Helius priority level used: Min, Low, Medium, High, VeryHigh, UnsafeMax - priorityFeePerCUEstimate: Type.Optional(Type.Number()), // Raw Helius estimate in lamports/CU (before minimum enforcement) + priorityFeePerCUEstimate: Type.Optional(DecimalNumber({})), // Raw Helius estimate in lamports/CU (before minimum enforcement) }, { $id: 'EstimateGasResponse' }, ); @@ -36,7 +71,7 @@ export type EstimateGasResponse = Static; export const BalanceRequestSchema = Type.Object( { - network: Type.Optional(Type.String()), + network: networkField(), address: Type.Optional(Type.String()), tokens: Type.Optional( Type.Array(Type.String(), { @@ -49,7 +84,7 @@ export const BalanceRequestSchema = Type.Object( }), ), }, - { $id: 'BalanceRequest' }, + { $id: 'BalanceRequest', additionalProperties: false }, ); export type BalanceRequestType = Static; @@ -63,10 +98,12 @@ export type BalanceResponseType = Static; export const TokensRequestSchema = Type.Object( { - network: Type.Optional(Type.String()), + network: networkField(), tokenSymbols: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])), }, - { $id: 'TokensRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type TokensRequestType = Static; @@ -81,25 +118,38 @@ export const TokensResponseSchema = Type.Object( }), ), }, - { $id: 'TokensResponse' }, + // No $id: no route serves this shape. The pool-scoped surfaces answer with the shared + // Chain* responses, so publishing this would put a name a caller reaches for on a + // shape they never receive. Kept as the base those responses compose from. ); export type TokensResponseType = Static; export const PollRequestSchema = Type.Object( { - network: Type.Optional(Type.String()), + network: networkField(), signature: Type.String({ description: 'Transaction signature/hash' }), }, - { $id: 'PollRequest' }, + { $id: 'PollRequest', additionalProperties: false }, ); export type PollRequestType = Static; +// Values reported in PollResponse.txStatus, shared by all chains. +export enum TransactionStatusCode { + NOT_FOUND = -2, // unknown to the chain: never received or dropped + FAILED = -1, // landed with an error / reverted + PENDING = 0, // seen by the chain, awaiting confirmation + CONFIRMED = 1, // landed without error +} + export const PollResponseSchema = Type.Object( { currentBlock: Type.Number(), signature: Type.String(), txBlock: Type.Union([Type.Number(), Type.Null()]), - txStatus: Type.Number({ description: 'Transaction status: 1 = confirmed, 0 = pending, -1 = failed' }), + txStatus: Type.Number({ + description: + 'Transaction status: 1 = confirmed, 0 = pending, -1 = failed, -2 = not found (unknown to the chain: never received or dropped; on Solana this is terminal once the transaction blockhash expires)', + }), fee: Type.Union([Type.Number(), Type.Null()]), error: Type.Union([Type.String({ description: 'Error info if failed: "TYPE (code): message"' }), Type.Null()]), txData: Type.Union([Type.Record(Type.String(), Type.Any()), Type.Null()]), @@ -110,9 +160,9 @@ export type PollResponseType = Static; export const StatusRequestSchema = Type.Object( { - network: Type.Optional(Type.String()), + network: networkField(), }, - { $id: 'StatusRequest' }, + { $id: 'StatusRequest', additionalProperties: false }, ); export type StatusRequestType = Static; @@ -139,22 +189,22 @@ export const ChainQuoteSwapResponseSchema = Type.Object( tokenOut: Type.String({ description: 'Address of the token being swapped to', }), - amountIn: Type.Number({ + amountIn: DecimalNumber({ description: 'Amount of tokenIn to be swapped', }), - amountOut: Type.Number({ + amountOut: DecimalNumber({ description: 'Expected amount of tokenOut to receive', }), - price: Type.Number({ + price: DecimalNumber({ description: 'Exchange rate between tokenIn and tokenOut', }), - priceImpactPct: Type.Number({ + priceImpactPct: DecimalNumber({ description: 'Estimated price impact percentage (0-100)', }), - minAmountOut: Type.Number({ + minAmountOut: DecimalNumber({ description: 'Minimum amount of tokenOut that will be accepted', }), - maxAmountIn: Type.Number({ + maxAmountIn: DecimalNumber({ description: 'Maximum amount of tokenIn that will be spent', }), // Optional fields that may be included by specific connectors @@ -169,7 +219,7 @@ export const ChainQuoteSwapResponseSchema = Type.Object( }), ), slippagePct: Type.Optional( - Type.Number({ + DecimalNumber({ description: 'Slippage tolerance percentage', }), ), @@ -189,31 +239,131 @@ export const ChainExecuteSwapResponseSchema = Type.Object( }), // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - tokenIn: Type.String({ - description: 'Address of the token swapped from', - }), - tokenOut: Type.String({ - description: 'Address of the token swapped to', - }), - amountIn: Type.Number({ - description: 'Actual amount of tokenIn swapped', - }), - amountOut: Type.Number({ - description: 'Actual amount of tokenOut received', - }), - fee: Type.Number({ - description: 'Transaction fee paid', - }), - baseTokenBalanceChange: Type.Number({ - description: 'Change in base token balance (negative for decrease)', - }), - quoteTokenBalanceChange: Type.Number({ - description: 'Change in quote token balance (negative for decrease)', - }), - }), + Type.Object( + { + tokenIn: Type.String({ + description: 'Address of the token swapped from', + }), + tokenOut: Type.String({ + description: 'Address of the token swapped to', + }), + amountIn: DecimalNumber({ + description: 'Actual amount of tokenIn swapped', + }), + amountOut: DecimalNumber({ + description: 'Actual amount of tokenOut received', + }), + fee: DecimalNumber({ + description: 'Transaction fee paid', + }), + baseTokenBalanceChange: DecimalNumber({ + description: 'Change in base token balance (negative for decrease)', + }), + quoteTokenBalanceChange: DecimalNumber({ + description: 'Change in quote token balance (negative for decrease)', + }), + slippagePct: Type.Optional( + DecimalNumber({ + description: 'Slippage tolerance percentage actually applied to the swap', + }), + ), + poolAddress: Type.Optional( + Type.String({ + description: + 'Pool the swap executed against. Set by the pool-scoped routes ' + + '(/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks ' + + 'its own path across pools and leaves this unset. Without it a settled fill ' + + 'cannot be reconciled to a venue without refetching the transaction.', + }), + ), + }, + { $id: 'ChainExecuteSwapResponseData' }, + ), ), }, { $id: 'ChainExecuteSwapResponse' }, ); export type ChainExecuteSwapResponseType = Static; + +// ============================================ +// Wrap / unwrap (shared by every chain with a wrapped native token) +// ============================================ +// The per-chain schemas these replace differed only in EVM's `nonce`, so it is +// optional here and simply absent on chains that have no nonce. + +export const WrapRequestSchema = Type.Object( + { + network: networkField(), + address: Type.String({ description: 'Wallet address holding the native token' }), + amount: Type.String({ + description: 'Amount of the native token to wrap, in whole units (not lamports/wei)', + examples: ['1.0', '0.5'], + }), + }, + { $id: 'WrapRequest', additionalProperties: false }, +); +export type WrapRequestType = Static; + +export const UnwrapRequestSchema = Type.Object( + { + network: networkField(), + address: Type.String({ description: 'Wallet address holding the wrapped token' }), + amount: Type.Optional( + Type.String({ + description: + 'Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.', + examples: ['1.0', '0.5'], + }), + ), + }, + { $id: 'UnwrapRequest', additionalProperties: false }, +); +export type UnwrapRequestType = Static; + +export const WrapResponseSchema = Type.Object( + { + signature: Type.String(), + status: Type.Number({ description: 'TransactionStatus enum value' }), + + // Only included when status = CONFIRMED + data: Type.Optional( + Type.Object( + { + nonce: Type.Optional(Type.Number({ description: 'EVM transaction nonce; absent on non-EVM chains' })), + fee: Type.String(), + amount: Type.String(), + wrappedAddress: Type.String(), + nativeToken: Type.String(), + wrappedToken: Type.String(), + }, + { $id: 'ChainWrapResponseData' }, + ), + ), + }, + { $id: 'ChainWrapResponse' }, +); +export type ChainWrapResponseType = Static; + +/** + * Router quote response: the shared swap-quote fields plus the two a router adds. + * + * `quoteId` is what makes /trading/router/execute-quote reachable — a quote whose + * id is stripped in serialization can never be executed by id — so the router + * surface cannot use the plain ChainQuoteSwapResponse. + */ +export const RouterQuoteSwapResponseSchema = Type.Composite( + [ + ChainQuoteSwapResponseSchema, + Type.Object({ + quoteId: Type.String({ description: 'Identifier to pass to /trading/router/execute-quote' }), + approximation: Type.Optional( + Type.Boolean({ + description: + 'True when a BUY was approximated via a sell-leg ExactIn quote because the router has no ExactOut route; amountOut is an estimate rather than exact', + }), + ), + }), + ], + { $id: 'RouterQuoteSwapResponse' }, +); +export type RouterQuoteSwapResponseType = Static; diff --git a/src/schemas/clmm-schema.ts b/src/schemas/clmm-schema.ts index ce1fc94f30..20f86ecab0 100644 --- a/src/schemas/clmm-schema.ts +++ b/src/schemas/clmm-schema.ts @@ -1,6 +1,6 @@ import { Type, Static } from '@sinclair/typebox'; -import { TransactionStatus } from './chain-schema'; +import { DecimalNumber } from './decimal-field'; export const FetchPoolsRequest = Type.Object( { @@ -24,7 +24,9 @@ export const FetchPoolsRequest = Type.Object( }), ), }, - { $id: 'FetchPoolsRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type FetchPoolsRequestType = Static; @@ -39,13 +41,35 @@ export const PoolListItemSchema = Type.Object( quoteTokenAddress: Type.String({ description: 'Quote token address' }), quoteTokenSymbol: Type.String({ description: 'Quote token symbol' }), binStep: Type.Number({ description: 'Bin step / tick spacing' }), - baseFee: Type.Number({ description: 'Base fee percentage' }), - price: Type.Number({ description: 'Current price' }), - tvl: Type.Number({ description: 'Total value locked in USD' }), - apr: Type.Optional(Type.Number({ description: 'Annual percentage rate' })), - apy: Type.Optional(Type.Number({ description: 'Annual percentage yield' })), - volume24h: Type.Optional(Type.Number({ description: '24-hour trading volume' })), - fees24h: Type.Optional(Type.Number({ description: '24-hour fees collected' })), + baseFee: DecimalNumber({ + description: 'Base fee percentage', + }), + price: DecimalNumber({ + description: 'Current price', + }), + tvl: DecimalNumber({ + description: 'Total value locked in USD', + }), + apr: Type.Optional( + DecimalNumber({ + description: 'Annual percentage rate', + }), + ), + apy: Type.Optional( + DecimalNumber({ + description: 'Annual percentage yield', + }), + ), + volume24h: Type.Optional( + DecimalNumber({ + description: '24-hour trading volume', + }), + ), + fees24h: Type.Optional( + DecimalNumber({ + description: '24-hour fees collected', + }), + ), }, { $id: 'PoolListItem' }, ); @@ -58,7 +82,7 @@ export const FetchPoolsResponse = Type.Object( page: Type.Number({ description: 'Current page number' }), pageSize: Type.Number({ description: 'Number of pools per page' }), }, - { $id: 'FetchPoolsResponse' }, + { $id: 'ClmmFetchPoolsResponse' }, ); export type FetchPoolsResponseType = Static; @@ -67,7 +91,9 @@ export const GetPositionsOwnedRequest = Type.Object( network: Type.Optional(Type.String()), walletAddress: Type.String(), }, - { $id: 'GetPositionsOwnedRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type GetPositionsOwnedRequestType = Static; @@ -75,9 +101,9 @@ export type GetPositionsOwnedRequestType = Static; @@ -115,7 +141,9 @@ export const MeteoraPoolInfoSchema = Type.Composite( maxBinId: Type.Number(), }), ], - { $id: 'MeteoraPoolInfo' }, + // No $id: a connector's extension of PoolInfo, not a response any route declares — the + // unified pool-info route answers with PoolInfo and drops these fields. Publishing it + // would advertise fields that never arrive. ); export type MeteoraPoolInfo = Static; @@ -134,7 +162,9 @@ export const GetPoolInfoRequest = Type.Object( }), ), }, - { $id: 'GetPoolInfoRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type GetPoolInfoRequestType = Static; @@ -144,19 +174,17 @@ export const PositionInfoSchema = Type.Object( poolAddress: Type.String(), baseTokenAddress: Type.String(), quoteTokenAddress: Type.String(), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - baseFeeAmount: Type.Number(), - quoteFeeAmount: Type.Number(), + baseTokenAmount: DecimalNumber({}), + quoteTokenAmount: DecimalNumber({}), + baseFeeAmount: DecimalNumber({}), + quoteFeeAmount: DecimalNumber({}), lowerBinId: Type.Number(), upperBinId: Type.Number(), - lowerPrice: Type.Number(), - upperPrice: Type.Number(), - price: Type.Number(), - rewardTokenAddress: Type.Optional(Type.String()), - rewardAmount: Type.Optional(Type.Number()), + lowerPrice: DecimalNumber({}), + upperPrice: DecimalNumber({}), + price: DecimalNumber({}), }, - { $id: 'PositionInfo' }, + { $id: 'ClmmPositionInfo' }, ); export type PositionInfo = Static; @@ -166,7 +194,9 @@ export const GetPositionInfoRequest = Type.Object( positionAddress: Type.String(), walletAddress: Type.Optional(Type.String()), }, - { $id: 'GetPositionInfoRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type GetPositionInfoRequestType = Static; @@ -174,14 +204,22 @@ export const OpenPositionRequest = Type.Object( { network: Type.Optional(Type.String()), walletAddress: Type.Optional(Type.String()), - lowerPrice: Type.Number(), - upperPrice: Type.Number(), + lowerPrice: Type.Number({ format: 'decimal' }), + upperPrice: Type.Number({ format: 'decimal' }), poolAddress: Type.String(), - baseTokenAmount: Type.Optional(Type.Number()), - quoteTokenAmount: Type.Optional(Type.Number()), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + baseTokenAmount: Type.Optional(Type.Number({ format: 'decimal' })), + quoteTokenAmount: Type.Optional(Type.Number({ format: 'decimal' })), + slippagePct: Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), + ), }, - { $id: 'OpenPositionRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type OpenPositionRequestType = Static; @@ -192,16 +230,23 @@ export const OpenPositionResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - positionAddress: Type.String(), - positionRent: Type.Number(), - baseTokenAmountAdded: Type.Number(), - quoteTokenAmountAdded: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + // The venue this write touched. Echoed so a stored record identifies its pool + // without the request that produced it — the same reason the swap execute + // responses carry it. + poolAddress: Type.Optional(Type.String({ description: 'Pool this operation acted on' })), + positionAddress: Type.String(), + positionRent: DecimalNumber({}), + baseTokenAmountAdded: DecimalNumber({}), + quoteTokenAmountAdded: DecimalNumber({}), + }, + { $id: 'ClmmOpenPositionResponseData' }, + ), ), }, - { $id: 'OpenPositionResponse' }, + { $id: 'ClmmOpenPositionResponse' }, ); export type OpenPositionResponseType = Static; @@ -210,11 +255,19 @@ export const AddLiquidityRequest = Type.Object( network: Type.Optional(Type.String()), walletAddress: Type.Optional(Type.String()), positionAddress: Type.String(), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + baseTokenAmount: Type.Number({ format: 'decimal' }), + quoteTokenAmount: Type.Number({ format: 'decimal' }), + slippagePct: Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), + ), }, - { $id: 'AddLiquidityRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type AddLiquidityRequestType = Static; @@ -225,14 +278,22 @@ export const AddLiquidityResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - baseTokenAmountAdded: Type.Number(), - quoteTokenAmountAdded: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + // The venue this write touched. Echoed so a stored record identifies its pool + // without the request that produced it — the same reason the swap execute + // responses carry it. + poolAddress: Type.Optional(Type.String({ description: 'Pool this operation acted on' })), + positionAddress: Type.Optional(Type.String({ description: 'Position this operation acted on' })), + baseTokenAmountAdded: DecimalNumber({}), + quoteTokenAmountAdded: DecimalNumber({}), + }, + { $id: 'ClmmAddLiquidityResponseData' }, + ), ), }, - { $id: 'AddLiquidityResponse' }, + { $id: 'ClmmAddLiquidityResponse' }, ); export type AddLiquidityResponseType = Static; @@ -241,9 +302,15 @@ export const RemoveLiquidityRequest = Type.Object( network: Type.Optional(Type.String()), walletAddress: Type.Optional(Type.String()), positionAddress: Type.String(), - percentageToRemove: Type.Number({ minimum: 0, maximum: 100 }), + percentageToRemove: Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), }, - { $id: 'RemoveLiquidityRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type RemoveLiquidityRequestType = Static; @@ -254,14 +321,22 @@ export const RemoveLiquidityResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - baseTokenAmountRemoved: Type.Number(), - quoteTokenAmountRemoved: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + // The venue this write touched. Echoed so a stored record identifies its pool + // without the request that produced it — the same reason the swap execute + // responses carry it. + poolAddress: Type.Optional(Type.String({ description: 'Pool this operation acted on' })), + positionAddress: Type.Optional(Type.String({ description: 'Position this operation acted on' })), + baseTokenAmountRemoved: DecimalNumber({}), + quoteTokenAmountRemoved: DecimalNumber({}), + }, + { $id: 'ClmmRemoveLiquidityResponseData' }, + ), ), }, - { $id: 'RemoveLiquidityResponse' }, + { $id: 'ClmmRemoveLiquidityResponse' }, ); export type RemoveLiquidityResponseType = Static; @@ -271,7 +346,9 @@ export const CollectFeesRequest = Type.Object( walletAddress: Type.Optional(Type.String()), positionAddress: Type.String(), }, - { $id: 'CollectFeesRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type CollectFeesRequestType = Static; @@ -282,14 +359,22 @@ export const CollectFeesResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - baseFeeAmountCollected: Type.Number(), - quoteFeeAmountCollected: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + // The venue this write touched. Echoed so a stored record identifies its pool + // without the request that produced it — the same reason the swap execute + // responses carry it. + poolAddress: Type.Optional(Type.String({ description: 'Pool this operation acted on' })), + positionAddress: Type.Optional(Type.String({ description: 'Position this operation acted on' })), + baseFeeAmountCollected: DecimalNumber({}), + quoteFeeAmountCollected: DecimalNumber({}), + }, + { $id: 'ClmmCollectFeesResponseData' }, + ), ), }, - { $id: 'CollectFeesResponse' }, + { $id: 'ClmmCollectFeesResponse' }, ); export type CollectFeesResponseType = Static; @@ -299,7 +384,9 @@ export const ClosePositionRequest = Type.Object( walletAddress: Type.Optional(Type.String()), positionAddress: Type.String(), }, - { $id: 'ClosePositionRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type ClosePositionRequestType = Static; @@ -310,33 +397,128 @@ export const ClosePositionResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - fee: Type.Number(), - positionRentRefunded: Type.Number(), - baseTokenAmountRemoved: Type.Number(), - quoteTokenAmountRemoved: Type.Number(), - baseFeeAmountCollected: Type.Number(), - quoteFeeAmountCollected: Type.Number(), - }), + Type.Object( + { + fee: DecimalNumber({}), + // The venue this write touched. Echoed so a stored record identifies its pool + // without the request that produced it — the same reason the swap execute + // responses carry it. + poolAddress: Type.Optional(Type.String({ description: 'Pool this operation acted on' })), + positionAddress: Type.Optional(Type.String({ description: 'Position this operation acted on' })), + positionRentRefunded: DecimalNumber({}), + baseTokenAmountRemoved: DecimalNumber({}), + quoteTokenAmountRemoved: DecimalNumber({}), + baseFeeAmountCollected: DecimalNumber({}), + quoteFeeAmountCollected: DecimalNumber({}), + }, + { $id: 'ClmmClosePositionResponseData' }, + ), ), }, - { $id: 'ClosePositionResponse' }, + { $id: 'ClmmClosePositionResponse' }, ); export type ClosePositionResponseType = Static; -export const QuotePositionRequest = Type.Omit(OpenPositionRequest, ['walletAddress'], { $id: 'QuotePositionRequest' }); +// ======================================== +// CLMM Create Pool Types +// ======================================== + +// One fee-tier vocabulary across connectors: binStep is the bin/tick granularity +// (Meteora DLMM bin step, Orca tick spacing), feeBps the base fee in basis points +// (Meteora DLMM base fee; Uniswap/PancakeSwap V3 tier — 1, 5, 30 or 100 bps, +// PancakeSwap also 25), ammConfigIndex the Raydium-family fee-config index +// (Raydium API config list; pancakeswap-sol amm_config PDA index). +export const CreatePoolRequest = Type.Object( + { + network: Type.Optional(Type.String()), + walletAddress: Type.Optional(Type.String()), + baseToken: Type.String(), + quoteToken: Type.String(), + initialPrice: Type.Optional( + Type.Number({ + format: 'decimal', + description: + 'Initial pool price as quote per base. If omitted, the current market price is fetched from the ' + + 'unified swap router so the pool opens on-market.', + }), + ), + binStep: Type.Optional( + Type.Number({ + 'x-connectors': ['meteora', 'orca'], + description: 'Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.', + }), + ), + feeBps: Type.Optional( + Type.Number({ + 'x-connectors': ['meteora', 'uniswap', 'pancakeswap'], + description: + 'Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier ' + + '(1, 5, 30 or 100 bps; PancakeSwap also 25).', + }), + ), + ammConfigIndex: Type.Optional( + Type.Number({ + 'x-connectors': ['raydium', 'pancakeswap-sol'], + description: + 'Fee-config index for the Raydium CLMM family: Raydium API config list index; ' + + 'pancakeswap-sol amm_config PDA index. Default 0.', + }), + ), + }, + // No $id: this is the pre-refactor shape (per-connector `network`, no `connector`), + // kept only as the base the unified route composes from. The request actually on the + // wire is the route's own schema, which now carries this name as its $id — publishing + // both would collide, and publishing this one would generate a client that sends the + // wrong keys. +); +export type CreatePoolRequestType = Static; + +// CLMM create-pool initializes an EMPTY pool — liquidity arrives later via +// open-position — so unlike the AMM response there are no seeded amounts. +export const CreatePoolResponse = Type.Object( + { + signature: Type.String(), + status: Type.Number({ description: 'TransactionStatus enum value' }), + poolAddress: Type.String({ description: 'Address of the newly created pool' }), + price: Type.Optional( + DecimalNumber({ + description: 'Initial price the pool was initialized at (quote per base)', + }), + ), + + // Only included when status = CONFIRMED + data: Type.Optional( + Type.Object( + { + fee: DecimalNumber({}), + }, + { $id: 'ClmmCreatePoolResponseData' }, + ), + ), + }, + { $id: 'ClmmCreatePoolResponse' }, +); +export type CreatePoolResponseType = Static; + +// No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as +// the base a unified route composes from. Publishing it would generate a client that +// sends the wrong keys under a name the real wire shape wants. +export const QuotePositionRequest = Type.Omit(OpenPositionRequest, ['walletAddress']); export type QuotePositionRequestType = Static; export const QuotePositionResponse = Type.Object( { + // The pool this split was computed against — on CLMM the caller need not have + // named one, and on AMM it keeps the quote self-describing alongside quote-swap. + poolAddress: Type.Optional(Type.String({ description: 'Pool the quote was computed against' })), baseLimited: Type.Boolean(), - baseTokenAmount: Type.Number(), - quoteTokenAmount: Type.Number(), - baseTokenAmountMax: Type.Number(), - quoteTokenAmountMax: Type.Number(), + baseTokenAmount: DecimalNumber({}), + quoteTokenAmount: DecimalNumber({}), + baseTokenAmountMax: DecimalNumber({}), + quoteTokenAmountMax: DecimalNumber({}), liquidity: Type.Optional(Type.Any()), }, - { $id: 'QuotePositionResponse' }, + { $id: 'ClmmQuoteLiquidityResponse' }, ); export type QuotePositionResponseType = Static; @@ -360,14 +542,22 @@ export const QuoteSwapRequest = Type.Object( description: 'The other token in the pair (optional - required if poolAddress not provided)', }), ), - amount: Type.Number(), + amount: Type.Number({ format: 'decimal' }), side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'], }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + slippagePct: Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), + ), }, - { $id: 'ClmmQuoteSwapRequest' }, + // No $id: this is the pre-refactor shape (per-connector `network`, no `connector`), + // kept only as the base the unified route composes from. The request actually on the + // wire is the route's own querystring, which now carries this name as its $id. ); export type QuoteSwapRequestType = Static; @@ -376,15 +566,17 @@ export const QuoteSwapResponse = Type.Object( poolAddress: Type.String(), tokenIn: Type.String(), tokenOut: Type.String(), - amountIn: Type.Number(), - amountOut: Type.Number(), - price: Type.Number(), - slippagePct: Type.Optional(Type.Number()), - minAmountOut: Type.Number(), - maxAmountIn: Type.Number(), - priceImpactPct: Type.Number(), + amountIn: DecimalNumber({}), + amountOut: DecimalNumber({}), + price: DecimalNumber({}), + slippagePct: Type.Optional(DecimalNumber({})), + minAmountOut: DecimalNumber({}), + maxAmountIn: DecimalNumber({}), + priceImpactPct: DecimalNumber({}), }, - { $id: 'ClmmQuoteSwapResponse' }, + // No $id: no route serves this shape. The pool-scoped surfaces answer with the shared + // Chain* responses, so publishing this would put a name a caller reaches for on a + // shape they never receive. Kept as the base those responses compose from. ); export type QuoteSwapResponseType = Static; @@ -403,13 +595,23 @@ export const ExecuteSwapRequest = Type.Object( description: 'The other token in the pair (optional - required if poolAddress not provided)', }), ), - amount: Type.Number(), + amount: Type.Number({ format: 'decimal' }), side: Type.String({ enum: ['BUY', 'SELL'], }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + slippagePct: Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + }), + ), }, - { $id: 'ClmmExecuteSwapRequest' }, + // No $id: this is the pre-refactor shape (per-connector `network`, no `connector`), + // kept only as the base the unified route composes from. The request actually on the + // wire is the route's own schema, which now carries this name as its $id — publishing + // both would collide, and publishing this one would generate a client that sends the + // wrong keys. ); export type ExecuteSwapRequestType = Static; @@ -420,17 +622,28 @@ export const ExecuteSwapResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - tokenIn: Type.String(), - tokenOut: Type.String(), - amountIn: Type.Number(), - amountOut: Type.Number(), - fee: Type.Number(), - baseTokenBalanceChange: Type.Number(), - quoteTokenBalanceChange: Type.Number(), - }), + Type.Object( + { + tokenIn: Type.String(), + tokenOut: Type.String(), + amountIn: DecimalNumber({}), + amountOut: DecimalNumber({}), + fee: DecimalNumber({}), + baseTokenBalanceChange: DecimalNumber({}), + quoteTokenBalanceChange: DecimalNumber({}), + slippagePct: Type.Optional( + DecimalNumber({ + description: 'Slippage tolerance percentage actually applied to the swap', + }), + ), + }, + // No $id: its parent is not published either — nothing would reference this, and a + // generated client would carry it as a class no response ever produces. + ), ), }, - { $id: 'ClmmExecuteSwapResponse' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type ExecuteSwapResponseType = Static; diff --git a/src/schemas/decimal-field.ts b/src/schemas/decimal-field.ts new file mode 100644 index 0000000000..1de217cead --- /dev/null +++ b/src/schemas/decimal-field.ts @@ -0,0 +1,34 @@ +import { Type } from '@sinclair/typebox'; + +/** + * A monetary quantity: a decimal string on the wire, a number in the code. + * + * Every money field used to be `DecimalNumber({ })`, which the spec emits + * as `"type": "number"` — an IEEE 754 double. A value that is an exact decimal on-chain + * (an atomic integer over 10^decimals) arrived as the nearest representable double, so a + * fee of 0.000037 was published as 0.00003700000000250725 and an input of 0.01 as + * 0.010000000000000002. The `format: 'decimal'` annotation was a hint no JSON parser acts + * on; it did not change what was on the wire. + * + * It defeated the consumer's own care. hummingbot-api models these as `Decimal`, which + * would be exact — but the value had already lost precision before pydantic saw it: + * `Decimal(str(0.00003700000000250725))` keeps the noise, `Decimal('0.000037')` does not. + * And the error is relative, so it grows with the number: a wei-denominated amount above + * ~9e15 cannot round-trip through a double at all. + * + * The static type stays `number` deliberately. These schemas are the domain types the + * connectors compute with — they multiply, compare and log them — and making that + * `string` would smear parsing through every one of them for no gain. Fastify serializes + * a number into a string-typed field, so the wire carries `"0.000037"` while the code + * carries `0.000037`: the conversion happens once, at the boundary, where it belongs. + * + * `format: 'decimal'` is kept because the model generators on both sides map + * `string` + that format to `Decimal`, which is what carries the exactness past the wire + * and into the caller. + * + * REQUESTS deliberately stay `Type.Number`. A string there would arrive as a string while + * the type still said number, and the handler would do arithmetic on it — worse than the + * problem. That half needs parsing at each boundary and is a separate change. + */ +export const DecimalNumber = (options: Record = {}) => + Type.Unsafe(Type.String({ format: 'decimal', ...options })); diff --git a/src/schemas/error-schema.ts b/src/schemas/error-schema.ts new file mode 100644 index 0000000000..51659555af --- /dev/null +++ b/src/schemas/error-schema.ts @@ -0,0 +1,34 @@ +import { Type } from '@sinclair/typebox'; + +import { ErrorCode } from '../services/error-handler'; + +/** + * The envelope every failed request answers with. + * + * Gateway has always returned this shape and never described it: of 56 operations, three + * declared any non-2xx response, so a generated client had models for success and nothing + * for failure — while `code` is precisely the field callers are supposed to branch on + * (`TRANSACTION_TIMEOUT` is retryable, `SLIPPAGE_EXCEEDED` is not). + * + * Attached to every operation by the swagger transform in `src/app.ts` rather than route + * by route, because it is the same envelope everywhere and a per-route list would drift. + */ +export const ErrorResponse = Type.Object( + { + statusCode: Type.Integer({ description: 'HTTP status code', examples: [400] }), + error: Type.String({ description: 'HTTP status name', examples: ['Bad Request'] }), + message: Type.String({ + description: 'What went wrong, in terms of the request that caused it', + examples: ["Connector 'meteora' runs on solana, not ethereum"], + }), + code: Type.Optional( + Type.String({ + description: + 'Machine-readable cause, present when Gateway can name one. This is what a caller ' + + 'branches on: TRANSACTION_TIMEOUT and RATE_LIMITED are retryable, the rest are not.', + enum: Object.values(ErrorCode), + }), + ), + }, + { $id: 'ErrorResponse' }, +); diff --git a/src/schemas/router-schema.ts b/src/schemas/router-schema.ts index 9ee6cc952f..dc2ad2169a 100644 --- a/src/schemas/router-schema.ts +++ b/src/schemas/router-schema.ts @@ -1,5 +1,7 @@ import { Type, Static } from '@sinclair/typebox'; +import { DecimalNumber } from './decimal-field'; + // ======================================== // Base request/response types for DEX aggregators // and other order router-based connectors @@ -19,6 +21,7 @@ export const QuoteSwapRequest = Type.Object( description: 'The other token in the pair', }), amount: Type.Number({ + format: 'decimal', description: 'Amount of base token to trade', }), side: Type.String({ @@ -28,6 +31,7 @@ export const QuoteSwapRequest = Type.Object( }), slippagePct: Type.Optional( Type.Number({ + format: 'decimal', minimum: 0, maximum: 100, description: 'Maximum acceptable slippage percentage', @@ -41,7 +45,9 @@ export const QuoteSwapRequest = Type.Object( }), ), }, - { $id: 'QuoteSwapRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type QuoteSwapRequestType = Static; @@ -56,22 +62,22 @@ export const QuoteSwapResponse = Type.Object( tokenOut: Type.String({ description: 'Address of the token being swapped to', }), - amountIn: Type.Number({ + amountIn: DecimalNumber({ description: 'Amount of tokenIn to be swapped', }), - amountOut: Type.Number({ + amountOut: DecimalNumber({ description: 'Expected amount of tokenOut to receive', }), - price: Type.Number({ + price: DecimalNumber({ description: 'Exchange rate between tokenIn and tokenOut', }), - priceImpactPct: Type.Number({ + priceImpactPct: DecimalNumber({ description: 'Estimated price impact percentage (0-100)', }), - minAmountOut: Type.Number({ + minAmountOut: DecimalNumber({ description: 'Minimum amount of tokenOut that will be accepted', }), - maxAmountIn: Type.Number({ + maxAmountIn: DecimalNumber({ description: 'Maximum amount of tokenIn that will be spent', }), approximation: Type.Optional( @@ -81,7 +87,9 @@ export const QuoteSwapResponse = Type.Object( }), ), }, - { $id: 'QuoteSwapResponse' }, + // No $id: no route serves this shape. The pool-scoped surfaces answer with the shared + // Chain* responses, so publishing this would put a name a caller reaches for on a + // shape they never receive. Kept as the base those responses compose from. ); export type QuoteSwapResponseType = Static; @@ -101,7 +109,9 @@ export const ExecuteQuoteRequest = Type.Object( description: 'ID of the quote to execute', }), }, - { $id: 'ExecuteQuoteRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type ExecuteQuoteRequestType = Static; @@ -124,6 +134,7 @@ export const ExecuteSwapRequest = Type.Object( description: 'The other token in the pair', }), amount: Type.Number({ + format: 'decimal', description: 'Amount of base token to trade', }), side: Type.String({ @@ -133,6 +144,7 @@ export const ExecuteSwapRequest = Type.Object( }), slippagePct: Type.Optional( Type.Number({ + format: 'decimal', minimum: 0, maximum: 100, description: 'Maximum acceptable slippage percentage', @@ -146,7 +158,9 @@ export const ExecuteSwapRequest = Type.Object( }), ), }, - { $id: 'ExecuteSwapRequest' }, + // No $id: the pre-refactor shape (per-connector `network`, no `connector`), kept only as + // the base a unified route composes from. Publishing it would generate a client that + // sends the wrong keys under a name the real wire shape wants. ); export type ExecuteSwapRequestType = Static; @@ -161,31 +175,42 @@ export const SwapExecuteResponse = Type.Object( // Only included when status = CONFIRMED data: Type.Optional( - Type.Object({ - tokenIn: Type.String({ - description: 'Address of the token swapped from', - }), - tokenOut: Type.String({ - description: 'Address of the token swapped to', - }), - amountIn: Type.Number({ - description: 'Actual amount of tokenIn swapped', - }), - amountOut: Type.Number({ - description: 'Actual amount of tokenOut received', - }), - fee: Type.Number({ - description: 'Transaction fee paid', - }), - baseTokenBalanceChange: Type.Number({ - description: 'Change in base token balance (negative for decrease)', - }), - quoteTokenBalanceChange: Type.Number({ - description: 'Change in quote token balance (negative for decrease)', - }), - }), + Type.Object( + { + tokenIn: Type.String({ + description: 'Address of the token swapped from', + }), + tokenOut: Type.String({ + description: 'Address of the token swapped to', + }), + amountIn: DecimalNumber({ + description: 'Actual amount of tokenIn swapped', + }), + amountOut: DecimalNumber({ + description: 'Actual amount of tokenOut received', + }), + fee: DecimalNumber({ + description: 'Transaction fee paid', + }), + baseTokenBalanceChange: DecimalNumber({ + description: 'Change in base token balance (negative for decrease)', + }), + quoteTokenBalanceChange: DecimalNumber({ + description: 'Change in quote token balance (negative for decrease)', + }), + slippagePct: Type.Optional( + DecimalNumber({ + description: 'Slippage tolerance percentage actually applied to the swap', + }), + ), + }, + // No $id: its parent is not published either — nothing would reference this, and a + // generated client would carry it as a class no response ever produces. + ), ), }, - { $id: 'SwapExecuteResponse' }, + // No $id: no route serves this shape. The pool-scoped surfaces answer with the shared + // Chain* responses, so publishing this would put a name a caller reaches for on a + // shape they never receive. Kept as the base those responses compose from. ); export type SwapExecuteResponseType = Static; diff --git a/src/services/chain-network.ts b/src/services/chain-network.ts new file mode 100644 index 0000000000..8d7dbbc85d --- /dev/null +++ b/src/services/chain-network.ts @@ -0,0 +1,33 @@ +/** + * Reading a `chain-network` selector, in one place. + * + * Three implementations had grown apart, and the differences were not stylistic. The one + * in `trading/common.ts` rejected a value with no hyphen; `ConfigManagerV2`'s accepted + * anything and answered `{ chain: 'solana', network: '' }` for `"solana"`; and + * `pools/routes/findPools.ts` hand-rolled `split('-').slice(1).join('-')` inline, which + * did the same silently. A caller could therefore be rejected or quietly given an empty + * network depending on which route it reached. + * + * The message deliberately contains "Invalid chainNetwork": `handlePoolError` matches on + * that to answer 400, so the pools routes report a malformed selector as the caller's + * error rather than as a Gateway failure. + * + * Not to be confused with `parseChainNetworkNamespace` in `config/utils.ts`, which asks a + * different question — whether a *config namespace* is a chain-network one at all — and + * answers null rather than throwing. + */ + +/** Split `chain-network` into its parts. Throws when it is not in that form. */ +export function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { + const [chain, ...networkParts] = (chainNetwork ?? '').split('-'); + const network = networkParts.join('-'); + + if (!chain || !network) { + throw new Error( + `Invalid chainNetwork '${chainNetwork}': expected chain-network, ` + + 'for example solana-mainnet-beta or ethereum-mainnet.', + ); + } + + return { chain, network }; +} diff --git a/src/services/config-manager-v2.ts b/src/services/config-manager-v2.ts index d7c0668c2a..4bbd149820 100644 --- a/src/services/config-manager-v2.ts +++ b/src/services/config-manager-v2.ts @@ -7,6 +7,7 @@ import yaml from 'js-yaml'; import { rootPath } from '../paths'; +import { parseChainNetwork as parseChainNetworkParts } from './chain-network'; import { httpErrors } from './error-handler'; type Configuration = { [key: string]: any }; @@ -226,10 +227,8 @@ export class ConfigurationNamespace { const pathComponents: Array = configPath.split('.'); const configClone: Configuration = JSON.parse(JSON.stringify(this.#configuration)); let cursor: Configuration | any = configClone; - let parent: Configuration = configClone; for (const component of pathComponents.slice(0, -1)) { - parent = cursor; cursor = cursor[component]; if (cursor === undefined) { parent[component] = {}; @@ -252,11 +251,9 @@ export class ConfigurationNamespace { const pathComponents: Array = configPath.split('.'); const configClone: Configuration = JSON.parse(JSON.stringify(this.#configuration)); let cursor: Configuration | any = configClone; - let parent: Configuration = configClone; // Navigate to the parent of the property we want to delete for (const component of pathComponents.slice(0, -1)) { - parent = cursor; cursor = cursor[component]; if (cursor === undefined) { return; // Property doesn't exist, nothing to delete @@ -528,9 +525,7 @@ export class ConfigManagerV2 { * Parse chain-network format into components */ parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const [chain, ...networkParts] = chainNetwork.split('-'); - const network = networkParts.join('-'); - return { chain, network }; + return parseChainNetworkParts(chainNetwork); } /** diff --git a/src/services/connection-manager.ts b/src/services/connection-manager.ts index 400fe795c7..18284b3f8c 100644 --- a/src/services/connection-manager.ts +++ b/src/services/connection-manager.ts @@ -16,7 +16,7 @@ export class UnsupportedChainException extends Error { } } -export async function getInitializedChain<_T>(chain: string, network: string): Promise { +export async function getInitializedChain(chain: string, network: string): Promise { const chainInstance = (await getChainInstance(chain, network)) as ChainInstance; if (chainInstance === undefined) { diff --git a/src/services/error-handler.ts b/src/services/error-handler.ts index dfb4911592..49d4baf5b1 100644 --- a/src/services/error-handler.ts +++ b/src/services/error-handler.ts @@ -5,6 +5,7 @@ export const ErrorCode = { TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', // Retryable - tx may have succeeded SIMULATION_FAILED: 'SIMULATION_FAILED', // Non-retryable - tx would fail on-chain + TRANSACTION_FAILED: 'TRANSACTION_FAILED', // Non-retryable - tx landed on-chain but failed (fees were paid) INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', // Non-retryable - not enough funds INVALID_PARAMS: 'INVALID_PARAMS', // Non-retryable - bad request params SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', // Non-retryable - price moved too much @@ -88,6 +89,10 @@ export function simulationFailed(message: string): HttpError { return new HttpError(400, message, ErrorCode.SIMULATION_FAILED); } +export function transactionFailed(message: string): HttpError { + return new HttpError(400, message, ErrorCode.TRANSACTION_FAILED); +} + export function insufficientBalance(message: string): HttpError { return new HttpError(400, message, ErrorCode.INSUFFICIENT_BALANCE); } @@ -115,6 +120,7 @@ export const httpErrors = { forbidden, transactionTimeout, simulationFailed, + transactionFailed, insufficientBalance, slippageExceeded, noRouteFound, diff --git a/src/services/gateway-security.ts b/src/services/gateway-security.ts index 1e7b8b2e57..05afc3d1b6 100644 --- a/src/services/gateway-security.ts +++ b/src/services/gateway-security.ts @@ -70,18 +70,32 @@ export function isTrustedLocalAddress(ip: string | undefined): boolean { /** Path prefixes that move funds or reveal/modify secrets — gated behind auth when exposed. */ const SENSITIVE_PREFIXES = [/^\/wallet(\/|$)/, /^\/config\/update(\/|$)/, /^\/restart(\/|$)/]; -const SENSITIVE_CONNECTOR = /^\/connectors\/[^/]+\/(amm|clmm|router)\/(execute|add|remove|open|close|collect)/i; -// Unified cross-chain trading namespace: only the fund-moving routes. Read-only routes -// (/trading/swap/quote, /trading/clmm/pool-info|position-info|positions-owned|quote-position) -// stay public by design. -const SENSITIVE_TRADING = /^\/trading\/(swap\/execute|clmm\/(open|add|remove|collect-fees|close))(\/|$)/i; +// Unified trading namespace: only the fund-moving routes. Read-only routes +// (quote-swap, quote-liquidity, pool-info, position-info, +// positions-owned, fetch-pools) stay public by design. +// +// This one pattern replaced a second one that covered the removed /connectors/* +// surface, so it has to cover every fund-moving verb that surface carried — +// including the AMM add/remove/create-pool routes, which the previous /trading +// pattern omitted only because /connectors was still gating them. +const SENSITIVE_TRADING = + /^\/trading\/(router\/(execute-swap|execute-quote)|(clmm|amm)\/(execute-swap|open|close|add|remove|collect-fees|create-pool))(\/|$)/i; + +// Chain-level routes that sign with the hot wallet. `approve` is the sharpest of them — +// an unauthenticated caller reaching it can have the wallet approve an unlimited +// allowance to an address of their choosing and then drain every ERC-20 the wallet holds, +// without ever touching a route the patterns above cover. `wrap`/`unwrap` sign and move +// the native balance. The read-only chain routes (status, estimate-gas, balances, poll, +// allowances) stay public, as the read-only trading routes do: nothing signs, and gating +// them would break a co-located bot's polling for no security gain. +const SENSITIVE_CHAINS = /^\/chains\/[^/]+\/(approve|wrap|unwrap)(\/|$)/i; export function isSensitivePath(url: string): boolean { const pathOnly = url.split('?')[0]; return ( SENSITIVE_PREFIXES.some((re) => re.test(pathOnly)) || - SENSITIVE_CONNECTOR.test(pathOnly) || - SENSITIVE_TRADING.test(pathOnly) + SENSITIVE_TRADING.test(pathOnly) || + SENSITIVE_CHAINS.test(pathOnly) ); } diff --git a/src/services/hardware-wallet-service.ts b/src/services/hardware-wallet-service.ts index 6bf426ffd9..5cca1c325f 100644 --- a/src/services/hardware-wallet-service.ts +++ b/src/services/hardware-wallet-service.ts @@ -1,7 +1,6 @@ import EthApp, { ledgerService } from '@ledgerhq/hw-app-eth'; import SolanaApp from '@ledgerhq/hw-app-solana'; import { Transaction, VersionedTransaction, PublicKey } from '@solana/web3.js'; -import bs58 from 'bs58'; import { LedgerTransportManager } from './ledger-transport'; import { logger } from './logger'; diff --git a/src/services/logger.ts b/src/services/logger.ts index a39c4c7be3..401cf4ba22 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -129,7 +129,11 @@ const toStdout = new winston.transports.Console({ }); export const updateLoggerToStdout = () => { - ConfigManagerV2.getInstance().get('server.logToStdOut') === true ? logger.add(toStdout) : logger.remove(toStdout); + if (ConfigManagerV2.getInstance().get('server.logToStdOut') === true) { + logger.add(toStdout); + } else { + logger.remove(toStdout); + } }; // Initialize logger with stdout configuration diff --git a/src/services/operation-ids.ts b/src/services/operation-ids.ts new file mode 100644 index 0000000000..3d1e2860d9 --- /dev/null +++ b/src/services/operation-ids.ts @@ -0,0 +1,88 @@ +/** + * The name each operation carries in a generated client. + * + * Without an `operationId` every generator invents one from the method and path, so the + * method a caller wrote against is renamed by any path change — the same churn that + * `refResolver` exists to keep out of the component names. Deriving them here from the + * path would reproduce exactly that, so they are chosen instead: a rename moves the key + * and leaves the name a caller depends on alone. + * + * They read as `` rather than mirroring the URL, because a client calls + * `gateway.openClmmPosition(...)`, not `gateway.postTradingClmmOpen(...)`. + * + * Keyed by `METHOD path` exactly as the route table spells it. + * `test/spec/operation-ids.test.ts` holds this to every route, in both directions. + */ +export const OPERATION_IDS: Record = { + // System configuration + 'GET /config/': 'getConfig', + 'POST /config/update': 'updateConfig', + 'GET /config/chains': 'listChains', + 'GET /config/connectors': 'listConnectors', + 'GET /config/namespaces': 'listNamespaces', + + // Wallets + 'GET /wallet/': 'listWallets', + 'POST /wallet/add': 'addWallet', + 'POST /wallet/add-hardware': 'addHardwareWallet', + 'DELETE /wallet/remove': 'removeWallet', + 'POST /wallet/setDefault': 'setDefaultWallet', + + // Tokens + 'GET /tokens/': 'listTokens', + 'POST /tokens/': 'addToken', + 'GET /tokens/{symbolOrAddress}': 'getToken', + 'GET /tokens/find/{address}': 'findToken', + 'POST /tokens/save/{address}': 'saveToken', + 'DELETE /tokens/{address}': 'removeToken', + + // Pools + 'GET /pools/': 'listPools', + 'POST /pools/': 'addPool', + 'GET /pools/{tradingPair}': 'getPool', + 'GET /pools/find': 'findPools', + 'GET /pools/find/{address}': 'findPool', + 'POST /pools/save/{address}': 'savePool', + 'DELETE /pools/{address}': 'removePool', + + // Chains + 'GET /chains/{chain}/status': 'getChainStatus', + 'GET /chains/{chain}/estimate-gas': 'estimateGas', + 'POST /chains/{chain}/balances': 'getBalances', + 'POST /chains/{chain}/poll': 'pollTransaction', + 'POST /chains/{chain}/wrap': 'wrapNativeToken', + 'POST /chains/{chain}/unwrap': 'unwrapNativeToken', + 'POST /chains/ethereum/allowances': 'getAllowances', + 'POST /chains/ethereum/approve': 'approveToken', + + // Router swaps + 'GET /trading/router/quote-swap': 'quoteRouterSwap', + 'POST /trading/router/execute-quote': 'executeRouterQuote', + 'POST /trading/router/execute-swap': 'executeRouterSwap', + + // Concentrated liquidity + 'GET /trading/clmm/quote-swap': 'quoteClmmSwap', + 'POST /trading/clmm/execute-swap': 'executeClmmSwap', + 'GET /trading/clmm/pool-info': 'getClmmPoolInfo', + 'GET /trading/clmm/fetch-pools': 'fetchClmmPools', + 'POST /trading/clmm/create-pool': 'createClmmPool', + 'GET /trading/clmm/position-info': 'getClmmPositionInfo', + 'GET /trading/clmm/positions-owned': 'listClmmPositions', + 'GET /trading/clmm/quote-liquidity': 'quoteClmmLiquidity', + 'POST /trading/clmm/open': 'openClmmPosition', + 'POST /trading/clmm/add': 'addClmmLiquidity', + 'POST /trading/clmm/remove': 'removeClmmLiquidity', + 'POST /trading/clmm/collect-fees': 'collectClmmFees', + 'POST /trading/clmm/close': 'closeClmmPosition', + + // Constant product + 'GET /trading/amm/quote-swap': 'quoteAmmSwap', + 'POST /trading/amm/execute-swap': 'executeAmmSwap', + 'GET /trading/amm/pool-info': 'getAmmPoolInfo', + 'POST /trading/amm/create-pool': 'createAmmPool', + 'GET /trading/amm/position-info': 'getAmmPositionInfo', + 'GET /trading/amm/positions-owned': 'listAmmPositions', + 'GET /trading/amm/quote-liquidity': 'quoteAmmLiquidity', + 'POST /trading/amm/add': 'addAmmLiquidity', + 'POST /trading/amm/remove': 'removeAmmLiquidity', +}; diff --git a/src/services/quote-cache.ts b/src/services/quote-cache.ts index c9ea946a11..05bf1ba8cd 100644 --- a/src/services/quote-cache.ts +++ b/src/services/quote-cache.ts @@ -41,6 +41,20 @@ class QuoteCache { return cached.quote; } + /** + * Get the original request data stored alongside a quote + * @param quoteId The unique quote identifier + * @returns The cached request data or null if not found + */ + public getRequest(quoteId: string): any | null { + const cached = this.cache.get(quoteId); + if (!cached) { + return null; + } + + return cached.request; + } + /** * Store a quote in cache * @param quoteId The unique quote identifier diff --git a/src/services/schema-keywords.ts b/src/services/schema-keywords.ts new file mode 100644 index 0000000000..46d7434bd2 --- /dev/null +++ b/src/services/schema-keywords.ts @@ -0,0 +1,72 @@ +/** + * Schema vocabulary the route schemas use beyond plain JSON Schema, declared to AJV. + * + * Fastify runs AJV in strict mode, which rejects a schema carrying a keyword or + * format it does not know — including the `x-` vendor extensions and annotations + * below, which are legal OpenAPI but meaningless to a validator. Declaring them + * keeps strict mode on for real mistakes while letting them reach the spec. + */ +export const SCHEMA_VENDOR_KEYWORDS = [ + // Names the connectors that actually honor an optional field. Several unified + // routes accept parameters only some connectors implement (approximateIfNoExactOut + // on Solana routers, indicativePrice on 0x, page/includeUnverified on Meteora, + // sortDirection/verifiedOnly on Orca); AJV strips a field the chosen connector + // ignores, so without this the only record of which connector honors what would + // be prose in a description. + 'x-connectors', +]; + +/** + * Marks a numeric field as a decimal quantity — a token amount, price, fee, or + * percentage — rather than a counter or identifier. + * + * It does not change the wire format: the value stays a JSON number, and JS has + * no decimal type to widen it to. What it does is carry the intent into the + * OpenAPI document, so a generated client can map the field to its language's + * decimal type (Python's Decimal, for one) instead of a float. Validation is a + * no-op — every JSON number qualifies. + */ +export const DECIMAL_FORMAT = { + decimal: { + type: 'number' as const, + validate: () => true, + }, +}; + +/** AJV options for every Fastify instance in the app (and in tests). */ +export const ajvOptions = { + customOptions: { + keywords: SCHEMA_VENDOR_KEYWORDS, + formats: DECIMAL_FORMAT, + // Fastify defaults this to true, which STRIPS a property the schema does not + // declare. Combined with `additionalProperties: false` on the request components, + // stripping is the wrong half of the pair: AJV would quietly delete the key and the + // route would run on what was left. `slippagePc: 5` on an execute-swap was dropped + // that way and the trade went out at the connector's configured slippage — the + // caller's stated tolerance, silently ignored. False makes the declaration mean what + // it says: an undeclared key is a 400, not a deletion. + removeAdditional: false, + }, +}; + +/** + * Names the property a request was rejected for. + * + * AJV's message for `additionalProperties` is "must NOT have additional properties", + * which tells a caller that something is wrong and not what. That is the whole point of + * rejecting the key rather than dropping it: `slippagePc` for `slippagePct` is a typo + * someone has to see. The offending name is in the error's params and nowhere in its + * message, so this puts it there. + * + * Every other keyword keeps Fastify's own wording, which callers and tests already match + * on ("body must have required property 'connector'"). + */ +export const schemaErrorFormatter = (errors: any[], dataVar: string): Error => { + const rendered = errors.map((error) => { + if (error.keyword === 'additionalProperties') { + return `${dataVar} has an unknown property '${error.params?.additionalProperty}'`; + } + return `${dataVar}${error.instancePath} ${error.message}`; + }); + return new Error(rendered.join(', ')); +}; diff --git a/src/services/token-pool-autosave.ts b/src/services/token-pool-autosave.ts new file mode 100644 index 0000000000..6d1fbcea71 --- /dev/null +++ b/src/services/token-pool-autosave.ts @@ -0,0 +1,163 @@ +/** + * Record the tokens and pools a caller trades, so Gateway learns them by being used. + * + * Gateway resolves a pool by token pair from its configured list, so a pool that is not + * in that list can only be traded by passing its address every time, and a token that is + * not in the token list has no symbol to pair by. Both are recoverable from the chain at + * the moment they are first used, which is what these do. + * + * Deliberately chain-only. `/pools/save` and `/tokens/save` read the same facts from + * GeckoTerminal, which is ~30 calls a minute shared across the whole process and spends + * up to five of them per pool. Name, symbol and decimals are all on-chain — ERC-20 view + * calls on Ethereum, the mint plus its metadata account on Solana — so these read the RPC + * that is already being paid for and no third-party quota is involved. + * + * Nothing here fails a caller's request. A swap that worked should not be reported as + * having failed because a bookkeeping write did not, so every path logs and returns. + */ +import { Ethereum } from '../chains/ethereum/ethereum'; +import { Solana } from '../chains/solana/solana'; +import { Pool } from '../pools/types'; +import { Token } from '../tokens/types'; + +import { logger } from './logger'; +import { PoolService } from './pool-service'; +import { TokenService } from './token-service'; + +/** + * Run a recording step so that it cannot affect the request that triggered it. + * + * The two functions below already handle their own failures, but a route that awaits + * them would turn any lapse in that — or any future caller that forgets — into a failed + * swap. The guarantee belongs at the point where a caller is waiting, so it is made here + * and the routes call through it. + */ +export const recordQuietly = async (work: Promise, context: string): Promise => { + try { + await work; + } catch (e: any) { + logger.warn(`Could not record ${context}: ${e.message}`); + } +}; + +/** Pool facts every connector's poolInfo reports, and all a pool record needs. */ +export interface PoolFacts { + address: string; + baseTokenAddress: string; + quoteTokenAddress: string; + feePct: number; +} + +const fetchFromChain = async (chain: string, network: string, address: string): Promise => { + if (chain === 'solana') { + return (await Solana.getInstance(network)).fetchTokenFromChain(address); + } + if (chain === 'ethereum') { + return (await Ethereum.getInstance(network)).fetchTokenFromChain(address); + } + return null; +}; + +/** + * The token at an address, adding it to the token list if it is not there yet. + * + * Returns null when the chain has no name for it. A token cannot be stored without a + * symbol — the list is keyed by one and pools pair by one — and inventing a placeholder + * would put a name into the list that no caller would ever ask for, under which the + * pools built on it would then be filed. + */ +export async function ensureTokenSaved(chain: string, network: string, address: string): Promise { + const tokenService = TokenService.getInstance(); + + const existing = await tokenService.getToken(chain, network, address); + if (existing) { + return existing; + } + + try { + const token = await fetchFromChain(chain, network, address); + if (!token) { + logger.info(`No on-chain metadata for ${address} on ${chain}/${network}; leaving it unlisted`); + return null; + } + + // addToken treats a symbol collision as an update and rewrites the existing entry's + // address, so a token whose on-chain symbol is already taken must not be written: + // wrapped SOL reports its symbol as "SOL", and saving it would repoint the SOL every + // other pool in the list pairs against. + const bySymbol = await tokenService.getToken(chain, network, token.symbol); + if (bySymbol && bySymbol.address.toLowerCase() !== token.address.toLowerCase()) { + logger.warn( + `Not adding ${token.address} on ${chain}/${network}: its on-chain symbol ${token.symbol} is already ` + + `held by ${bySymbol.address}. Add it under a distinct symbol with POST /tokens if it is wanted.`, + ); + return null; + } + + await tokenService.addToken(chain, network, token); + logger.info(`Learned token ${token.symbol} (${token.address}) on ${chain}/${network} from the chain`); + return token; + } catch (e: any) { + logger.warn(`Could not record token ${address} on ${chain}/${network}: ${e.message}`); + return null; + } +} + +/** + * The pool at an address, adding it and its two tokens to Gateway's lists if missing. + * + * `fetchPoolInfo` is a thunk rather than a value so a pool that is already known costs + * one list read and no RPC — which is the common case, since a pool is only unknown the + * first time it is traded. + */ +export async function ensurePoolSaved(args: { + chain: string; + network: string; + connector: string; + type: 'amm' | 'clmm'; + poolAddress: string; + fetchPoolInfo: () => Promise; +}): Promise { + const { chain, network, connector, type, poolAddress } = args; + const poolService = PoolService.getInstance(); + + try { + if (await poolService.getPoolByAddress(chain, network, poolAddress)) { + return; + } + + const info = await args.fetchPoolInfo(); + const [base, quote] = await Promise.all([ + ensureTokenSaved(chain, network, info.baseTokenAddress), + ensureTokenSaved(chain, network, info.quoteTokenAddress), + ]); + + // A pool is filed under its pair, so an unnamed side leaves nothing to file it under. + if (!base || !quote) { + logger.info( + `Not recording pool ${poolAddress} on ${chain}/${network}: ` + + `${!base ? info.baseTokenAddress : info.quoteTokenAddress} has no symbol`, + ); + return; + } + + const pool: Pool = { + connector, + type, + network, + address: info.address || poolAddress, + baseSymbol: base.symbol, + quoteSymbol: quote.symbol, + baseTokenAddress: base.address, + quoteTokenAddress: quote.address, + feePct: info.feePct, + }; + + await poolService.addPool(chain, network, pool); + logger.info( + `Learned ${type} pool ${pool.baseSymbol}-${pool.quoteSymbol} (${pool.address}) on ${connector}/${network}`, + ); + } catch (e: any) { + logger.warn(`Could not record pool ${poolAddress} on ${chain}/${network}: ${e.message}`); + } +} diff --git a/src/services/token-service.ts b/src/services/token-service.ts index 9c349fb86b..0bbe237382 100644 --- a/src/services/token-service.ts +++ b/src/services/token-service.ts @@ -13,7 +13,6 @@ import { logger } from './logger'; const writeFile = promisify(fs.writeFile); const readFile = promisify(fs.readFile); -const exists = promisify(fs.exists); export class TokenService { private static instance: TokenService; diff --git a/src/templates/chains/ethereum/bsc.yml b/src/templates/chains/ethereum/bsc.yml index 567f5157b9..be5392b1c6 100644 --- a/src/templates/chains/ethereum/bsc.yml +++ b/src/templates/chains/ethereum/bsc.yml @@ -1,6 +1,6 @@ chainID: 56 geckoId: bsc -nodeURL: https://binance.llamarpc.com +nodeURL: https://bsc-dataseed.bnbchain.org nativeCurrencySymbol: BNB transactionExecutionTimeoutMs: 10000 # Timeout for waiting for transaction execution (in milliseconds) swapProvider: pancakeswap/router diff --git a/src/templates/chains/ethereum/mainnet.yml b/src/templates/chains/ethereum/mainnet.yml index 1e0677a034..86b848df4c 100644 --- a/src/templates/chains/ethereum/mainnet.yml +++ b/src/templates/chains/ethereum/mainnet.yml @@ -1,5 +1,5 @@ chainID: 1 -nodeURL: https://eth.llamarpc.com +nodeURL: https://eth-mainnet.g.alchemy.com/public nativeCurrencySymbol: ETH geckoId: eth transactionExecutionTimeoutMs: 10000 # Timeout for waiting for transaction execution (in milliseconds) diff --git a/src/templates/namespace/ethereum-network-schema.json b/src/templates/namespace/ethereum-network-schema.json index c5fa12d2d2..1ffb68ad86 100644 --- a/src/templates/namespace/ethereum-network-schema.json +++ b/src/templates/namespace/ethereum-network-schema.json @@ -11,7 +11,7 @@ }, "swapProvider": { "type": "string", - "description": "Default swap provider used by the unified /trading/swap routes when no connector is specified (format: connector/type)", + "description": "Default swap provider used by the unified /trading/{router,clmm,amm} routes when no connector is specified (format: connector/type)", "enum": [ "uniswap/router", "0x/router", diff --git a/src/templates/namespace/solana-network-schema.json b/src/templates/namespace/solana-network-schema.json index 059ab19f3d..7634f2f036 100644 --- a/src/templates/namespace/solana-network-schema.json +++ b/src/templates/namespace/solana-network-schema.json @@ -14,7 +14,7 @@ }, "swapProvider": { "type": "string", - "description": "Default swap provider used by the unified /trading/swap routes when no connector is specified (format: connector/type)", + "description": "Default swap provider used by the unified /trading/{router,clmm,amm} routes when no connector is specified (format: connector/type)", "enum": [ "jupiter/router", "dflow/router", diff --git a/src/templates/tokens/ethereum/sepolia.json b/src/templates/tokens/ethereum/sepolia.json index 83d28d6823..53b7595ca1 100644 --- a/src/templates/tokens/ethereum/sepolia.json +++ b/src/templates/tokens/ethereum/sepolia.json @@ -20,4 +20,4 @@ "symbol": "UNI", "decimals": 18 } -] \ No newline at end of file +] diff --git a/src/templates/tokens/solana/devnet.json b/src/templates/tokens/solana/devnet.json index 32ad20230a..a771cadff3 100644 --- a/src/templates/tokens/solana/devnet.json +++ b/src/templates/tokens/solana/devnet.json @@ -1,30 +1,30 @@ [ - { - "chainId": 103, - "name": "Dev USDC", - "symbol": "devUSDC", - "address": "BRjpCHtyQLNCo8gqRUr8jtdAj5AjPYQaoqbvcZiHok1k", - "decimals": 6 - }, - { - "chainId": 103, - "name": "Dev USDT", - "symbol": "devUSDT", - "address": "H8UekPGwePSmQ3ttuYGPU1szyFfjZR4N53rymSFwpLPm", - "decimals": 6 - }, - { - "chainId": 103, - "name": "Dev SAMO", - "symbol": "devSAMO", - "address": "Jd4M8bfJG3sAkd82RsGWyEXoaBXQP7njFzBwEaCTuDa", - "decimals": 9 - }, - { - "chainId": 103, - "name": "Dev TMAC", - "symbol": "devTMAC", - "address": "Afn8YB1p4NsoZeS5XJBZ18LTfEy5NFPwN46wapZcBQr6", - "decimals": 6 - } -] \ No newline at end of file + { + "chainId": 103, + "name": "Dev USDC", + "symbol": "devUSDC", + "address": "BRjpCHtyQLNCo8gqRUr8jtdAj5AjPYQaoqbvcZiHok1k", + "decimals": 6 + }, + { + "chainId": 103, + "name": "Dev USDT", + "symbol": "devUSDT", + "address": "H8UekPGwePSmQ3ttuYGPU1szyFfjZR4N53rymSFwpLPm", + "decimals": 6 + }, + { + "chainId": 103, + "name": "Dev SAMO", + "symbol": "devSAMO", + "address": "Jd4M8bfJG3sAkd82RsGWyEXoaBXQP7njFzBwEaCTuDa", + "decimals": 9 + }, + { + "chainId": 103, + "name": "Dev TMAC", + "symbol": "devTMAC", + "address": "Afn8YB1p4NsoZeS5XJBZ18LTfEy5NFPwN46wapZcBQr6", + "decimals": 6 + } +] diff --git a/src/tokens/routes/addToken.ts b/src/tokens/routes/addToken.ts index c68b87196f..100269c77e 100644 --- a/src/tokens/routes/addToken.ts +++ b/src/tokens/routes/addToken.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { TokenService } from '../../services/token-service'; import { TokenAddRequest, @@ -23,7 +24,8 @@ export const addTokenRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request) => { - const { chain, network, token } = request.body; + const { chainNetwork, token } = request.body; + const { chain, network } = parseChainNetwork(chainNetwork); try { const tokenService = TokenService.getInstance(); diff --git a/src/tokens/routes/getToken.ts b/src/tokens/routes/getToken.ts index 7d24f53691..c7dae30d0e 100644 --- a/src/tokens/routes/getToken.ts +++ b/src/tokens/routes/getToken.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { TokenService } from '../../services/token-service'; import { TokenViewQuery, TokenViewQuerySchema, TokenResponse, TokenResponseSchema } from '../schemas'; import { handleTokenError } from '../token-error-handler'; @@ -33,7 +34,8 @@ export const getTokenRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { const { symbolOrAddress } = request.params; - const { chain, network } = request.query; + const { chainNetwork } = request.query; + const { chain, network } = parseChainNetwork(chainNetwork); try { const tokenService = TokenService.getInstance(); @@ -45,8 +47,7 @@ export const getTokenRoute: FastifyPluginAsync = async (fastify) => { return { token, - chain, - network, + chainNetwork, }; } catch (error) { // Don't log "not found" errors as they are expected when searching across chains diff --git a/src/tokens/routes/listTokens.ts b/src/tokens/routes/listTokens.ts index 0913b08b70..b2dcfe4461 100644 --- a/src/tokens/routes/listTokens.ts +++ b/src/tokens/routes/listTokens.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { logger } from '../../services/logger'; import { TokenService } from '../../services/token-service'; import { TokenListQuery, TokenListQuerySchema, TokenListResponse, TokenListResponseSchema } from '../schemas'; @@ -18,7 +19,8 @@ export const listTokensRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request) => { - const { chain, network, search } = request.query; + const { chainNetwork, search } = request.query; + const { chain, network } = parseChainNetwork(chainNetwork); try { if (!chain || !network) { diff --git a/src/tokens/routes/removeToken.ts b/src/tokens/routes/removeToken.ts index f01ac29b81..81b34a2dde 100644 --- a/src/tokens/routes/removeToken.ts +++ b/src/tokens/routes/removeToken.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; +import { parseChainNetwork } from '../../services/chain-network'; import { TokenService } from '../../services/token-service'; import { TokenRemoveQuery, @@ -38,7 +39,8 @@ export const removeTokenRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { const { address } = request.params; - const { chain, network } = request.query; + const { chainNetwork } = request.query; + const { chain, network } = parseChainNetwork(chainNetwork); try { const tokenService = TokenService.getInstance(); diff --git a/src/tokens/schemas.ts b/src/tokens/schemas.ts index 38a33368d1..39f833ebcd 100644 --- a/src/tokens/schemas.ts +++ b/src/tokens/schemas.ts @@ -1,34 +1,37 @@ import { Type } from '@sinclair/typebox'; -import { ConfigManagerV2 } from '../services/config-manager-v2'; +import { chainNetworkField } from '../schemas/chain-network-field'; // Individual token structure -export const TokenSchema = Type.Object({ - chainId: Type.Optional( - Type.Number({ - description: 'The chain ID', - examples: [1, 101, 137], +export const TokenSchema = Type.Object( + { + chainId: Type.Optional( + Type.Number({ + description: 'The chain ID', + examples: [1, 101, 137], + }), + ), + name: Type.String({ + description: 'The full name of the token', + examples: ['USD Coin', 'Wrapped Ether'], }), - ), - name: Type.String({ - description: 'The full name of the token', - examples: ['USD Coin', 'Wrapped Ether'], - }), - symbol: Type.String({ - description: 'The token symbol', - examples: ['USDC', 'WETH'], - }), - address: Type.String({ - description: 'The token contract address', - examples: ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'], - }), - decimals: Type.Number({ - description: 'The number of decimals the token uses', - minimum: 0, - maximum: 255, - examples: [6, 18], - }), -}); + symbol: Type.String({ + description: 'The token symbol', + examples: ['USDC', 'WETH'], + }), + address: Type.String({ + description: 'The token contract address', + examples: ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'], + }), + decimals: Type.Number({ + description: 'The number of decimals the token uses', + minimum: 0, + maximum: 255, + examples: [6, 18], + }), + }, + { $id: 'Token' }, +); export type Token = { chainId?: number; @@ -40,18 +43,7 @@ export type Token = { // Query parameters for listing tokens export const TokenListQuerySchema = Type.Object({ - chain: Type.Optional( - Type.String({ - description: 'Blockchain network (e.g., ethereum, solana)', - examples: ['ethereum', 'solana'], - }), - ), - network: Type.Optional( - Type.String({ - description: 'Network name (e.g., mainnet, mainnet-beta)', - examples: ['mainnet', 'mainnet-beta', 'devnet'], - }), - ), + chainNetwork: chainNetworkField({ defaulted: false }), search: Type.Optional( Type.String({ description: 'Search term for filtering tokens by symbol or name', @@ -64,28 +56,14 @@ export type TokenListQuery = typeof TokenListQuerySchema.static; // Query parameters for viewing a specific token export const TokenViewQuerySchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain network (e.g., ethereum, solana)', - examples: ['ethereum', 'solana'], - }), - network: Type.String({ - description: 'Network name (e.g., mainnet, mainnet-beta)', - examples: ['mainnet', 'mainnet-beta', 'devnet'], - }), + chainNetwork: chainNetworkField({ defaulted: false }), }); export type TokenViewQuery = typeof TokenViewQuerySchema.static; // Request body for adding a token export const TokenAddRequestSchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain network (e.g., ethereum, solana)', - examples: ['ethereum', 'solana'], - }), - network: Type.String({ - description: 'Network name (e.g., mainnet, mainnet-beta)', - examples: ['mainnet', 'mainnet-beta', 'devnet'], - }), + chainNetwork: chainNetworkField({ defaulted: false }), token: TokenSchema, }); @@ -93,14 +71,7 @@ export type TokenAddRequest = typeof TokenAddRequestSchema.static; // Query parameters for removing a token export const TokenRemoveQuerySchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain network (e.g., ethereum, solana)', - examples: ['ethereum', 'solana'], - }), - network: Type.String({ - description: 'Network name (e.g., mainnet, mainnet-beta)', - examples: ['mainnet', 'mainnet-beta', 'devnet'], - }), + chainNetwork: chainNetworkField({ defaulted: false }), }); export type TokenRemoveQuery = typeof TokenRemoveQuerySchema.static; @@ -115,8 +86,8 @@ export type TokenListResponse = typeof TokenListResponseSchema.static; // Response format for single token export const TokenResponseSchema = Type.Object({ token: TokenSchema, - chain: Type.String(), - network: Type.String(), + // Echoes the selector the request used, in the form the request used it. + chainNetwork: Type.String(), }); export type TokenResponse = typeof TokenResponseSchema.static; diff --git a/src/trading/clmm/index.ts b/src/trading/clmm/index.ts new file mode 100644 index 0000000000..8619b354c6 --- /dev/null +++ b/src/trading/clmm/index.ts @@ -0,0 +1,11 @@ +/** + * The CLMM read routes and the querystrings they accept. + * + * The schemas are exported so app.ts can publish them as spec components — see + * `identifiedSchemas`. These four routes live here rather than in trading-clmm-routes, + * so without this barrel nothing collects them. + */ +export { poolsRoute, UnifiedPoolInfoRequestSchema } from './pools'; +export { positionsRoute, UnifiedPositionInfoRequestSchema } from './positions'; +export { positionsOwnedRoute, UnifiedPositionsOwnedRequestSchema } from './positions-owned'; +export { quoteLiquidityRoute, UnifiedQuotePositionRequestSchema } from './quote-liquidity'; diff --git a/src/trading/clmm/pools.ts b/src/trading/clmm/pools.ts index bd35c68926..fd24db8b1b 100644 --- a/src/trading/clmm/pools.ts +++ b/src/trading/clmm/pools.ts @@ -1,8 +1,6 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; -import { getEthereumNetworkConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaNetworkConfig } from '../../chains/solana/solana.config'; import { getPoolInfo as meteoraGetPoolInfo } from '../../connectors/meteora/clmm-routes/poolInfo'; import { getPoolInfo as orcaGetPoolInfo } from '../../connectors/orca/clmm-routes/poolInfo'; import { getPoolInfo as pancakeswapGetPoolInfo } from '../../connectors/pancakeswap/clmm-routes/poolInfo'; @@ -11,6 +9,8 @@ import { getPoolInfo as raydiumGetPoolInfo } from '../../connectors/raydium/clmm import { getPoolInfo as uniswapGetPoolInfo } from '../../connectors/uniswap/clmm-routes/poolInfo'; import { PoolInfo, PoolInfoSchema } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; +import { ensurePoolSaved, recordQuietly } from '../../services/token-pool-autosave'; +import { chainNetworkField, CLMM_CONNECTORS, connectorField, parseChainNetwork, rethrowRouteError } from '../common'; // Constants for examples (using Meteora CLMM values) const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; @@ -18,44 +18,31 @@ const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3' /** * Unified pool info request schema */ -const UnifiedPoolInfoRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - poolAddress: Type.String({ - description: 'Pool contract address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), -}); +export const UnifiedPoolInfoRequestSchema = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), + poolAddress: Type.String({ + description: 'Pool contract address', + examples: [CLMM_POOL_ADDRESS_EXAMPLE], + }), + binCount: Type.Optional( + Type.Integer({ + description: + 'If > 0, include a `bins` array of per-tick liquidity around the active tick. Supported by ' + + 'every connector except Meteora, which always returns its bins and ignores this. ' + + 'Default 0 = skip the bin fetch.', + default: 0, + minimum: 0, + maximum: 401, + }), + ), + }, + { $id: 'ClmmPoolInfoRequest', additionalProperties: false }, +); type UnifiedPoolInfoRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Get pool info from Solana connectors */ @@ -64,18 +51,20 @@ async function getSolanaPoolInfo( connector: string, network: string, poolAddress: string, + binCount: number, ): Promise { logger.info(`[CLMM] Getting pool info from ${connector} on solana/${network}`); switch (connector) { case 'raydium': - return await raydiumGetPoolInfo(fastify, network, poolAddress); + return await raydiumGetPoolInfo(fastify, network, poolAddress, binCount); case 'meteora': + // Meteora always returns its bins; it has no binCount parameter. return await meteoraGetPoolInfo(fastify, network, poolAddress); case 'pancakeswap-sol': - return await pancakeswapSolGetPoolInfo(fastify, network, poolAddress); + return await pancakeswapSolGetPoolInfo(fastify, network, poolAddress, binCount); case 'orca': - return await orcaGetPoolInfo(fastify, network, poolAddress); + return await orcaGetPoolInfo(fastify, network, poolAddress, binCount); default: throw fastify.httpErrors.badRequest(`Unsupported Solana CLMM connector: ${connector}`); } @@ -89,14 +78,15 @@ async function getEthereumPoolInfo( connector: string, network: string, poolAddress: string, + binCount: number, ): Promise { logger.info(`[CLMM] Getting pool info from ${connector} on ethereum/${network}`); switch (connector) { case 'uniswap': - return await uniswapGetPoolInfo(fastify, network, poolAddress); + return await uniswapGetPoolInfo(fastify, network, poolAddress, binCount); case 'pancakeswap': - return await pancakeswapGetPoolInfo(fastify, network, poolAddress); + return await pancakeswapGetPoolInfo(fastify, network, poolAddress, binCount); default: throw fastify.httpErrors.badRequest(`Unsupported Ethereum CLMM connector: ${connector}`); } @@ -110,6 +100,7 @@ export async function getUnifiedPoolInfo( connector: string, chainNetwork: string, poolAddress: string, + binCount: number = 0, ): Promise { const { chain, network } = parseChainNetwork(chainNetwork); @@ -117,10 +108,10 @@ export async function getUnifiedPoolInfo( switch (chain.toLowerCase()) { case 'ethereum': - return getEthereumPoolInfo(fastify, connector, network, poolAddress); + return getEthereumPoolInfo(fastify, connector, network, poolAddress, binCount); case 'solana': - return getSolanaPoolInfo(fastify, connector, network, poolAddress); + return getSolanaPoolInfo(fastify, connector, network, poolAddress, binCount); default: throw fastify.httpErrors.badRequest(`Unsupported chain: ${chain}`); @@ -148,14 +139,30 @@ export const poolsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const { connector, chainNetwork, poolAddress } = request.query; + const { connector, chainNetwork, poolAddress, binCount = 0 } = request.query; try { - const result = await getUnifiedPoolInfo(fastify, connector, chainNetwork, poolAddress); + const result = await getUnifiedPoolInfo(fastify, connector, chainNetwork, poolAddress, binCount); + + // Asking about a pool by address is the moment Gateway can learn it: the reply + // already carries both token addresses and the fee, so recording it costs the + // list read below and nothing more when it is already known. + const { chain, network } = parseChainNetwork(chainNetwork); + await recordQuietly( + ensurePoolSaved({ + chain, + network, + connector, + type: 'clmm', + poolAddress, + fetchPoolInfo: async () => result, + }), + `pool ${poolAddress}`, + ); + return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedCLMM] Pool info error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to get CLMM pool info'); } }, ); diff --git a/src/trading/clmm/positions-owned.ts b/src/trading/clmm/positions-owned.ts index af6756aa1e..d8db53b67d 100644 --- a/src/trading/clmm/positions-owned.ts +++ b/src/trading/clmm/positions-owned.ts @@ -1,8 +1,6 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { getPositionsOwned as meteoraGetPositionsOwned } from '../../connectors/meteora/clmm-routes/positionsOwned'; import { getPositionsOwned as orcaGetPositionsOwned } from '../../connectors/orca/clmm-routes/positionsOwned'; import { getPositionsOwned as pancakeswapGetPositionsOwned } from '../../connectors/pancakeswap/clmm-routes/positionsOwned'; @@ -11,60 +9,32 @@ import { getPositionsOwned as raydiumGetPositionsOwned } from '../../connectors/ import { getPositionsOwned as uniswapGetPositionsOwned } from '../../connectors/uniswap/clmm-routes/positionsOwned'; import { PositionInfo, PositionInfoSchema } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, +} from '../common'; /** * Unified positions owned request schema */ -const UnifiedPositionsOwnedRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address (optional, uses default wallet if not provided)', +export const UnifiedPositionsOwnedRequestSchema = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address', default: defaultWallet, }), - ), -}); + }, + { $id: 'ClmmPositionsOwnedRequest', additionalProperties: false }, +); type UnifiedPositionsOwnedRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Get positions owned from Solana connectors */ @@ -163,8 +133,7 @@ export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { const result = await getUnifiedPositionsOwned(fastify, connector, chainNetwork, walletAddress); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedCLMM] Positions owned error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to list CLMM positions owned'); } }, ); diff --git a/src/trading/clmm/positions.ts b/src/trading/clmm/positions.ts index 26582eea1d..ef17226ce7 100644 --- a/src/trading/clmm/positions.ts +++ b/src/trading/clmm/positions.ts @@ -9,48 +9,25 @@ import { getPositionInfo as raydiumGetPositionInfo } from '../../connectors/rayd import { getPositionInfo as uniswapGetPositionInfo } from '../../connectors/uniswap/clmm-routes/positionInfo'; import { PositionInfo, PositionInfoSchema } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; +import { chainNetworkField, CLMM_CONNECTORS, connectorField, parseChainNetwork, rethrowRouteError } from '../common'; /** * Unified position info request schema */ -const UnifiedPositionInfoRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - positionAddress: Type.String({ - description: 'Position address or NFT token ID', - examples: [''], - }), -}); +export const UnifiedPositionInfoRequestSchema = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), + positionAddress: Type.String({ + description: 'Position address or NFT token ID', + examples: [''], + }), + }, + { $id: 'ClmmPositionInfoRequest', additionalProperties: false }, +); type UnifiedPositionInfoRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Get position info from Solana connectors */ @@ -149,8 +126,7 @@ export const positionsRoute: FastifyPluginAsync = async (fastify) => { const result = await getUnifiedPositionInfo(fastify, connector, chainNetwork, positionAddress); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedCLMM] Position info error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to get CLMM position info'); } }, ); diff --git a/src/trading/clmm/quote-position.ts b/src/trading/clmm/quote-liquidity.ts similarity index 71% rename from src/trading/clmm/quote-position.ts rename to src/trading/clmm/quote-liquidity.ts index d1a44ec22f..5452eb8ee2 100644 --- a/src/trading/clmm/quote-position.ts +++ b/src/trading/clmm/quote-liquidity.ts @@ -1,8 +1,6 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { quotePosition as meteoraQuotePosition } from '../../connectors/meteora/clmm-routes/quotePosition'; import { quotePosition as orcaQuotePosition } from '../../connectors/orca/clmm-routes/quotePosition'; import { quotePosition as pancakeswapQuotePosition } from '../../connectors/pancakeswap/clmm-routes/quotePosition'; @@ -12,6 +10,14 @@ import { quotePosition as uniswapQuotePosition } from '../../connectors/uniswap/ import { QuotePositionResponseType, QuotePositionResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; import { logger } from '../../services/logger'; +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; // Constants for examples (using Meteora CLMM values) const BASE_TOKEN_AMOUNT = 0.01; @@ -23,73 +29,45 @@ const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3' /** * Unified quote position request schema */ -const UnifiedQuotePositionRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Pool contract address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], +export const UnifiedQuotePositionRequestSchema = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), + lowerPrice: Type.Number({ + format: 'decimal', + description: 'Lower price bound for the position', + examples: [LOWER_PRICE_BOUND], }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], + upperPrice: Type.Number({ + format: 'decimal', + description: 'Upper price bound for the position', + examples: [UPPER_PRICE_BOUND], }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: 1, - examples: [1], + poolAddress: Type.String({ + description: 'Pool contract address', + examples: [CLMM_POOL_ADDRESS_EXAMPLE], }), - ), -}); + baseTokenAmount: Type.Optional( + Type.Number({ + format: 'decimal', + description: 'Amount of base token to deposit', + examples: [BASE_TOKEN_AMOUNT], + }), + ), + quoteTokenAmount: Type.Optional( + Type.Number({ + format: 'decimal', + description: 'Amount of quote token to deposit', + examples: [QUOTE_TOKEN_AMOUNT], + }), + ), + slippagePct: slippagePctField(), + }, + { $id: 'ClmmQuoteLiquidityRequest', additionalProperties: false }, +); type UnifiedQuotePositionRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Quote position from Solana connectors */ @@ -134,6 +112,7 @@ async function getSolanaQuotePosition( poolAddress, baseTokenAmount, quoteTokenAmount, + slippagePct, ); case 'orca': return await orcaQuotePosition( @@ -240,14 +219,14 @@ export async function getUnifiedQuotePosition( /** * Unified CLMM quote position route - * GET /trading/clmm/quote-position + * GET /trading/clmm/quote-liquidity */ -export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { +export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: UnifiedQuotePositionRequest; Reply: QuotePositionResponseType; }>( - '/quote-position', + '/quote-liquidity', { schema: { description: 'Quote amounts for a new CLMM position from any supported connector', @@ -281,13 +260,14 @@ export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, slippagePct, ); - return reply.code(200).send(result); + // Names the pool the split was computed against, so the quote is self-describing + // alongside the write that follows it. + return reply.code(200).send({ ...result, poolAddress }); } catch (error: any) { - logger.error(`[UnifiedCLMM] Quote position error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to quote CLMM position'); } }, ); }; -export default quotePositionRoute; +export default quoteLiquidityRoute; diff --git a/src/trading/common.ts b/src/trading/common.ts new file mode 100644 index 0000000000..2fde2bad42 --- /dev/null +++ b/src/trading/common.ts @@ -0,0 +1,237 @@ +import { Type } from '@sinclair/typebox'; + +import { getEthereumChainConfig, getEthereumNetworkConfig } from '../chains/ethereum/ethereum.config'; +import { getSolanaChainConfig, getSolanaNetworkConfig } from '../chains/solana/solana.config'; +import { DecimalNumber } from '../schemas/decimal-field'; +import { parseChainNetwork as parseChainNetworkParts } from '../services/chain-network'; +import { httpErrors } from '../services/error-handler'; +import { logger } from '../services/logger'; +import { PoolService } from '../services/pool-service'; + +import { assertConnectorOnChain, TradingType } from './connector-registry'; + +/** + * The connector rosters the unified routes publish as their `connector` enum. + * + * Re-exported from the registry rather than listed again here: a second list is a + * second thing to update, and the two silently disagreeing is how a connector ends up + * offered by a schema that nothing can dispatch. + */ +export { AMM_CONNECTORS, CLMM_CONNECTORS } from './connector-registry'; + +/** + * The chain-network selector and the roster behind it, defined in `services/chain-network` + * so the pool and token routes can share the one field rather than each declaring its own. + */ +export { chainNetworkField, SUPPORTED_CHAIN_NETWORKS } from '../schemas/chain-network-field'; + +/** Connector selector: enum-constrained so unknown connectors are rejected at the schema. */ +/** + * The venue to act on. + * + * `defaulted: false` on every route that signs. AJV injects schema defaults before the + * handler runs, so a default here answers "which venue?" for a caller who never said — + * and the answer is whichever connector happens to be first in the registry. On a read + * that is a convenience; on a write it picks a venue for someone's money. The reads keep + * it, which is also what fills the Swagger form. + */ +export const connectorField = (connectors: string[], label: string, { defaulted = true } = {}) => + Type.String({ + description: label, + enum: connectors, + ...(defaulted ? { default: connectors[0] } : {}), + examples: [connectors[0]], + }); + +/** + * Optional slippage override shared by the unified trading routes. Deliberately + * has NO schema default: Fastify injects schema defaults before the handler + * runs, so a default here would shadow the connector-level defaults. When + * omitted, each connector applies its own configured slippagePct (falling back + * to 1 where the config has none). + */ +export const slippagePctField = (description?: string) => + Type.Optional( + Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + description: + description ?? "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + examples: [1], + }), + ); + +/** + * Standard catch handler for the unified trading routes: errors that already + * carry an HTTP status code (connector badRequest/notFound, chain errors) pass + * through untouched; anything else becomes a 500 that keeps the underlying + * message so callers see the real cause instead of a generic label. + */ +export function rethrowRouteError(e: any, context: string): never { + logger.error(`${context}: ${e?.message ?? e}`); + if (e?.statusCode) { + throw e; + } + throw httpErrors.internalServerError(`${context}: ${e?.message ?? e}`); +} + +/** + * Resolve a `chain-network` selector for a pool-scoped route, against the connector it + * was sent with. + * + * `parseChainNetwork` returns whatever it split, so a route that reads only the network + * half dispatches on the connector alone and the chain is decorative: `ethereum-mainnet` + * with a Solana connector ran that connector on `mainnet`, and a chain that exists + * nowhere ran it, successfully, on the network half. Every route that names one + * connector for one pool should resolve its selector through here, so the pair is + * checked once, in the same place, with the same message the swap routes give. + */ +export function resolveChainNetwork( + chainNetwork: string, + connector: string, + type: 'clmm' | 'amm', +): { chain: string; network: string } { + const { chain, network } = parseChainNetwork(chainNetwork); + assertConnectorOnChain(connector, chain, type); + return { chain, network }; +} + +/** + * Parse a chain-network string (e.g. "solana-mainnet-beta") into its chain and network. + * + * The split itself lives in `services/chain-network`; what this adds is the HTTP framing, + * so a malformed selector reaches the caller as a 400 rather than a 500. + */ +export function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { + try { + return parseChainNetworkParts(chainNetwork); + } catch (e: any) { + throw httpErrors.badRequest(e.message); + } +} + +// Default wallet from Solana config, falling back to Ethereum when Solana is unavailable. +let dw: string; +try { + dw = getSolanaChainConfig().defaultWallet; +} catch { + dw = getEthereumChainConfig().defaultWallet; +} +export const defaultWallet = dw; + +/** Wallet selector shared by the unified execute routes. */ +export const walletAddressField = (description = 'Wallet address that will execute the transaction') => + Type.String({ description, default: defaultWallet }); + +/** + * Pool pin for the pool-scoped (amm/clmm) swap routes. Optional: when omitted the + * pool is resolved from Gateway's configured pool list by token pair, which a pool + * that is not in that list (freshly created, unlisted token) cannot be — pass its + * address here for those. + */ +export const poolAddressField = () => + Type.Optional( + Type.String({ + description: + "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; " + + 'pass an address to pin a pool that is not in that list.', + }), + ); + +/** + * The connector a swap should use, honoring the network's configured swapProvider + * when the caller names none. + * + * The config stores a provider as "connector/type" (e.g. "jupiter/router"), while + * the unified routes carry the type in the path. So a configured default is only + * usable on the route matching its type; on any other route, omitting the connector + * is an error that names the config value rather than silently picking a connector. + */ +export function resolveSwapConnector(chain: string, network: string, type: TradingType, requested?: string): string { + if (requested) { + // Tolerate a typed value ("jupiter/router") so callers migrating from the old + // /trading/swap routes are not broken by the path-carries-the-type change. The + // route schemas constrain `connector` to bare names, so this path is reached by + // internal callers (pool creation's market-price lookup) passing a config value. + const [name, requestedType] = requested.split('/'); + if (requestedType && requestedType !== type) { + throw httpErrors.badRequest( + `Connector '${requested}' is a ${requestedType} provider, but this is a ${type} route. ` + + `Use /trading/${requestedType}/ instead, or pass a ${type} connector.`, + ); + } + return name; + } + + const swapProvider = + chain === 'solana' + ? getSolanaNetworkConfig(network)?.swapProvider + : getEthereumNetworkConfig(network)?.swapProvider; + + if (!swapProvider) { + throw httpErrors.badRequest( + `No connector given and no swapProvider configured for ${chain}-${network}. Pass a connector.`, + ); + } + + const [name, configuredType] = swapProvider.split('/'); + if (configuredType !== type) { + throw httpErrors.badRequest( + `No connector given. The configured swapProvider for ${chain}-${network} is '${swapProvider}', ` + + `which is a ${configuredType} provider — pass a ${type} connector explicitly.`, + ); + } + return name; +} + +/** + * Pool address for a pool-scoped swap: the caller's pin when given, otherwise the + * pair's pool from Gateway's configured list. + */ +export async function resolvePoolAddress( + chain: string, + network: string, + type: 'clmm' | 'amm', + connector: string, + baseToken: string, + quoteToken: string, + requested?: string, +): Promise { + if (requested) return requested; + + const pool = await PoolService.getInstance().getPool(chain, network, type, baseToken, quoteToken, connector); + if (!pool) { + throw httpErrors.notFound( + `No ${type.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connector}/${network}. ` + + 'Pass poolAddress to trade against a specific pool.', + ); + } + logger.info(`Resolved pool ${pool.address} for ${baseToken}-${quoteToken} on ${connector}/${network}`); + return pool.address; +} + +/** + * Stamp the identifiers a write acted on onto its confirmed result. + * + * A settled transaction should say which pool and position it touched without the + * caller holding on to the request that produced it — the same reason the swap + * execute responses carry `poolAddress`. Only the confirmed `data` block is + * decorated: a submitted-but-unconfirmed response has no data, and inventing one + * would claim the write landed. + * + * Undefined identifiers are dropped rather than written as undefined, so a + * fungible-LP AMM (which has no position) simply has no positionAddress. + */ +export function withIdentifiers }>( + result: T, + identifiers: { poolAddress?: string; positionAddress?: string }, +): T { + if (!result?.data) return result; + + const stamped = { ...result.data }; + for (const [key, value] of Object.entries(identifiers)) { + if (value !== undefined) stamped[key] = value; + } + return { ...result, data: stamped }; +} diff --git a/src/trading/connector-registry.ts b/src/trading/connector-registry.ts new file mode 100644 index 0000000000..42aa55e6bd --- /dev/null +++ b/src/trading/connector-registry.ts @@ -0,0 +1,464 @@ +/** + * The single table wiring every connector into the unified /trading routes. + * + * Before this existed, each unified route carried its own if-else chain over + * `connector/type` strings, so adding a connector meant editing every route and + * the chains diverged (a connector reachable from quote but not execute). Here a + * connector is one entry, and the routes are pure dispatch. + * + * Connector modules export plain async functions alongside their (now removed) + * per-connector route plugins; those functions are what the adapters below call. + * Each type has ONE argument shape, so the positional differences between + * connectors — titan taking a wallet, uniswap taking it second, 0x taking + * `indicativePrice` where the Solana routers take `approximateIfNoExactOut` — + * are absorbed here instead of leaking into the routes. + */ + +// Router connectors +import { executeQuote as zeroXExecuteQuote } from '../connectors/0x/router-routes/executeQuote'; +import { executeSwap as zeroXExecuteSwap } from '../connectors/0x/router-routes/executeSwap'; +import { quoteSwap as zeroXQuoteSwap } from '../connectors/0x/router-routes/quoteSwap'; +import { executeQuote as dflowExecuteQuote } from '../connectors/dflow/router-routes/executeQuote'; +import { executeSwap as dflowExecuteSwap } from '../connectors/dflow/router-routes/executeSwap'; +import { quoteSwap as dflowQuoteSwap } from '../connectors/dflow/router-routes/quoteSwap'; +import { executeQuote as jupiterExecuteQuote } from '../connectors/jupiter/router-routes/executeQuote'; +import { executeSwap as jupiterExecuteSwap } from '../connectors/jupiter/router-routes/executeSwap'; +import { quoteSwap as jupiterQuoteSwap } from '../connectors/jupiter/router-routes/quoteSwap'; +// AMM / CLMM connectors +import { executeSwap as meteoraAmmExecuteSwap } from '../connectors/meteora/amm-routes/executeSwap'; +import { quoteSwap as meteoraAmmQuoteSwap } from '../connectors/meteora/amm-routes/quoteSwap'; +import { executeSwap as meteoraClmmExecuteSwap } from '../connectors/meteora/clmm-routes/executeSwap'; +import { fetchPools as meteoraFetchPools } from '../connectors/meteora/clmm-routes/fetchPools'; +import { quoteSwap as meteoraClmmQuoteSwap } from '../connectors/meteora/clmm-routes/quoteSwap'; +import { executeQuote as okxExecuteQuote } from '../connectors/okx/router-routes/executeQuote'; +import { executeSwap as okxExecuteSwap } from '../connectors/okx/router-routes/executeSwap'; +import { quoteSwap as okxQuoteSwap } from '../connectors/okx/router-routes/quoteSwap'; +import { executeSwap as orcaClmmExecuteSwap } from '../connectors/orca/clmm-routes/executeSwap'; +import { fetchPools as orcaFetchPools } from '../connectors/orca/clmm-routes/fetchPools'; +import { quoteSwap as orcaClmmQuoteSwap } from '../connectors/orca/clmm-routes/quoteSwap'; +import { executeSwap as pancakeswapAmmExecuteSwap } from '../connectors/pancakeswap/amm-routes/executeSwap'; +import { quoteSwap as pancakeswapAmmQuoteSwap } from '../connectors/pancakeswap/amm-routes/quoteSwap'; +import { executeSwap as pancakeswapClmmExecuteSwap } from '../connectors/pancakeswap/clmm-routes/executeSwap'; +import { quoteSwap as pancakeswapClmmQuoteSwap } from '../connectors/pancakeswap/clmm-routes/quoteSwap'; +import { executeQuote as pancakeswapExecuteQuote } from '../connectors/pancakeswap/router-routes/executeQuote'; +import { executeSwap as pancakeswapExecuteSwap } from '../connectors/pancakeswap/router-routes/executeSwap'; +import { quoteSwap as pancakeswapQuoteSwap } from '../connectors/pancakeswap/router-routes/quoteSwap'; +import { executeSwap as pancakeswapSolClmmExecuteSwap } from '../connectors/pancakeswap-sol/clmm-routes/executeSwap'; +import { quoteSwap as pancakeswapSolClmmQuoteSwap } from '../connectors/pancakeswap-sol/clmm-routes/quoteSwap'; +import { executeSwap as raydiumAmmExecuteSwap } from '../connectors/raydium/amm-routes/executeSwap'; +import { quoteSwap as raydiumAmmQuoteSwap } from '../connectors/raydium/amm-routes/quoteSwap'; +import { executeSwap as raydiumClmmExecuteSwap } from '../connectors/raydium/clmm-routes/executeSwap'; +import { quoteSwap as raydiumClmmQuoteSwap } from '../connectors/raydium/clmm-routes/quoteSwap'; +import { executeQuote as titanExecuteQuote } from '../connectors/titan/router-routes/executeQuote'; +import { executeSwap as titanExecuteSwap } from '../connectors/titan/router-routes/executeSwap'; +import { quoteSwap as titanQuoteSwap } from '../connectors/titan/router-routes/quoteSwap'; +import { executeSwap as uniswapAmmExecuteSwap } from '../connectors/uniswap/amm-routes/executeSwap'; +import { quoteSwap as uniswapAmmQuoteSwap } from '../connectors/uniswap/amm-routes/quoteSwap'; +import { executeSwap as uniswapClmmExecuteSwap } from '../connectors/uniswap/clmm-routes/executeSwap'; +import { quoteSwap as uniswapClmmQuoteSwap } from '../connectors/uniswap/clmm-routes/quoteSwap'; +import { executeQuote as uniswapExecuteQuote } from '../connectors/uniswap/router-routes/executeQuote'; +import { executeSwap as uniswapExecuteSwap } from '../connectors/uniswap/router-routes/executeSwap'; +import { quoteSwap as uniswapQuoteSwap } from '../connectors/uniswap/router-routes/quoteSwap'; +import { FetchPoolsResponseType } from '../schemas/clmm-schema'; +import { httpErrors } from '../services/error-handler'; + +export type TradingType = 'router' | 'clmm' | 'amm'; + +/** Arguments every router quote takes. Connector-specific extras are optional. */ +export interface RouterQuoteArgs { + network: string; + baseToken: string; + quoteToken: string; + amount: number; + side: 'BUY' | 'SELL'; + slippagePct?: number; + /** Solana routers only: approximate a BUY via a sell-leg quote when the router has no ExactOut route. */ + approximateIfNoExactOut?: boolean; + /** Some routers price against the taker (titan quotes per-wallet, EVM routers build calldata for it). */ + walletAddress?: string; + /** + * 0x only: ask for an indicative price rather than a firm, executable quote. + * An indicative quote is cheaper but cannot be executed by id, so this must stay + * reachable now that the per-connector 0x route is gone. + */ + indicativePrice?: boolean; +} + +export interface RouterExecuteArgs extends RouterQuoteArgs { + walletAddress: string; +} + +/** Arguments every pool-scoped (amm/clmm) quote takes. */ +export interface PoolQuoteArgs { + network: string; + poolAddress: string; + baseToken: string; + side: 'BUY' | 'SELL'; + amount: number; + slippagePct?: number; +} + +export interface PoolExecuteArgs extends PoolQuoteArgs { + walletAddress: string; +} + +export interface FetchPoolsArgs { + network: string; + limit?: number; + query?: string; + sortBy?: string; + /** meteora only */ + page?: number; + /** meteora only */ + includeUnverified?: boolean; + /** orca only */ + sortDirection?: string; + /** orca only */ + verifiedOnly?: boolean; +} + +interface RouterOps { + chain: 'solana' | 'ethereum'; + quoteSwap: (args: RouterQuoteArgs) => Promise; + executeSwap: (args: RouterExecuteArgs) => Promise; + executeQuote: (walletAddress: string, network: string, quoteId: string) => Promise; +} + +interface PoolOps { + chain: 'solana' | 'ethereum'; + quoteSwap: (args: PoolQuoteArgs) => Promise; + executeSwap: (args: PoolExecuteArgs) => Promise; + /** Only connectors whose DEX exposes a pool-discovery API implement this. */ + fetchPools?: (args: FetchPoolsArgs) => Promise; +} + +// ============================================ +// Router connectors +// ============================================ + +const ROUTER_REGISTRY: Record = { + jupiter: { + chain: 'solana', + quoteSwap: (a) => + jupiterQuoteSwap( + a.network, + a.baseToken, + a.quoteToken, + a.amount, + a.side, + a.slippagePct, + a.approximateIfNoExactOut, + ), + executeSwap: (a) => + jupiterExecuteSwap( + a.walletAddress, + a.network, + a.baseToken, + a.quoteToken, + a.amount, + a.side, + a.slippagePct, + a.approximateIfNoExactOut, + ), + executeQuote: jupiterExecuteQuote, + }, + dflow: { + chain: 'solana', + quoteSwap: (a) => + dflowQuoteSwap(a.network, a.baseToken, a.quoteToken, a.amount, a.side, a.slippagePct, a.approximateIfNoExactOut), + executeSwap: (a) => + dflowExecuteSwap( + a.walletAddress, + a.network, + a.baseToken, + a.quoteToken, + a.amount, + a.side, + a.slippagePct, + a.approximateIfNoExactOut, + ), + executeQuote: dflowExecuteQuote, + }, + okx: { + chain: 'solana', + quoteSwap: (a) => + okxQuoteSwap(a.network, a.baseToken, a.quoteToken, a.amount, a.side, a.slippagePct, a.approximateIfNoExactOut), + executeSwap: (a) => + okxExecuteSwap( + a.walletAddress, + a.network, + a.baseToken, + a.quoteToken, + a.amount, + a.side, + a.slippagePct, + a.approximateIfNoExactOut, + ), + executeQuote: okxExecuteQuote, + }, + titan: { + chain: 'solana', + // Titan prices per-taker, so the wallet is part of the quote rather than only the execute. + quoteSwap: (a) => + titanQuoteSwap( + a.network, + a.baseToken, + a.quoteToken, + a.amount, + a.side, + a.slippagePct, + a.approximateIfNoExactOut, + a.walletAddress, + ), + executeSwap: (a) => + titanExecuteSwap( + a.walletAddress, + a.network, + a.baseToken, + a.quoteToken, + a.amount, + a.side, + a.slippagePct, + a.approximateIfNoExactOut, + ), + executeQuote: titanExecuteQuote, + }, + uniswap: { + chain: 'ethereum', + quoteSwap: (a) => + uniswapQuoteSwap(a.network, a.walletAddress, a.baseToken, a.quoteToken, a.amount, a.side, a.slippagePct), + executeSwap: (a) => + uniswapExecuteSwap(a.walletAddress, a.network, a.baseToken, a.quoteToken, a.amount, a.side, a.slippagePct), + executeQuote: uniswapExecuteQuote, + }, + pancakeswap: { + chain: 'ethereum', + quoteSwap: (a) => + pancakeswapQuoteSwap(a.network, a.walletAddress, a.baseToken, a.quoteToken, a.amount, a.side, a.slippagePct), + executeSwap: (a) => + pancakeswapExecuteSwap(a.walletAddress, a.network, a.baseToken, a.quoteToken, a.amount, a.side, a.slippagePct), + executeQuote: pancakeswapExecuteQuote, + }, + '0x': { + chain: 'ethereum', + // 0x takes `indicativePrice` where the Solana routers take approximateIfNoExactOut, + // and prices a firm quote against a taker. Both are passed through; leaving + // indicativePrice undefined lets 0x apply its own default. + quoteSwap: (a) => + zeroXQuoteSwap( + a.network, + a.baseToken, + a.quoteToken, + a.amount, + a.side, + a.slippagePct, + a.indicativePrice, + a.walletAddress, + ), + executeSwap: (a) => + zeroXExecuteSwap(a.walletAddress, a.network, a.baseToken, a.quoteToken, a.amount, a.side, a.slippagePct), + executeQuote: zeroXExecuteQuote, + }, +}; + +// ============================================ +// CLMM connectors +// ============================================ + +const CLMM_REGISTRY: Record = { + meteora: { + chain: 'solana', + quoteSwap: (a) => meteoraClmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + meteoraClmmExecuteSwap(a.network, a.walletAddress, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + fetchPools: (a) => + meteoraFetchPools({ + network: a.network, + limit: a.limit, + query: a.query, + sortBy: a.sortBy, + page: a.page, + includeUnverified: a.includeUnverified, + }), + }, + raydium: { + chain: 'solana', + quoteSwap: (a) => raydiumClmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + raydiumClmmExecuteSwap(a.network, a.walletAddress, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + }, + orca: { + chain: 'solana', + quoteSwap: (a) => orcaClmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + orcaClmmExecuteSwap(a.network, a.walletAddress, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + fetchPools: (a) => + orcaFetchPools({ + network: a.network, + limit: a.limit, + query: a.query, + sortBy: a.sortBy, + sortDirection: a.sortDirection, + verifiedOnly: a.verifiedOnly, + }), + }, + 'pancakeswap-sol': { + chain: 'solana', + quoteSwap: (a) => + pancakeswapSolClmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + pancakeswapSolClmmExecuteSwap( + a.network, + a.walletAddress, + a.poolAddress, + a.baseToken, + a.side, + a.amount, + a.slippagePct, + ), + }, + uniswap: { + chain: 'ethereum', + quoteSwap: (a) => uniswapClmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + uniswapClmmExecuteSwap(a.network, a.walletAddress, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + }, + pancakeswap: { + chain: 'ethereum', + quoteSwap: (a) => pancakeswapClmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + pancakeswapClmmExecuteSwap( + a.network, + a.walletAddress, + a.poolAddress, + a.baseToken, + a.side, + a.amount, + a.slippagePct, + ), + }, +}; + +// ============================================ +// AMM connectors +// ============================================ + +const AMM_REGISTRY: Record = { + meteora: { + chain: 'solana', + quoteSwap: (a) => meteoraAmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + meteoraAmmExecuteSwap(a.network, a.walletAddress, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + }, + raydium: { + chain: 'solana', + quoteSwap: (a) => raydiumAmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + raydiumAmmExecuteSwap(a.network, a.walletAddress, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + }, + uniswap: { + chain: 'ethereum', + quoteSwap: (a) => uniswapAmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + uniswapAmmExecuteSwap(a.network, a.walletAddress, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + }, + pancakeswap: { + chain: 'ethereum', + quoteSwap: (a) => pancakeswapAmmQuoteSwap(a.network, a.poolAddress, a.baseToken, a.side, a.amount, a.slippagePct), + executeSwap: (a) => + pancakeswapAmmExecuteSwap( + a.network, + a.walletAddress, + a.poolAddress, + a.baseToken, + a.side, + a.amount, + a.slippagePct, + ), + }, +}; + +/** + * Connector names backing each unified trading surface, in schema-enum order. + * + * These are the enums the route schemas publish, so the roster a caller can name is + * the roster this table can dispatch. They are re-exported from `./common`, which is + * where the routes import their shared fields from. + */ +export const ROUTER_CONNECTORS = Object.keys(ROUTER_REGISTRY); +export const CLMM_CONNECTORS = Object.keys(CLMM_REGISTRY); +export const AMM_CONNECTORS = Object.keys(AMM_REGISTRY); + +/** Connectors whose DEX exposes a pool-discovery API (`/trading/clmm/fetch-pools`). */ +export const FETCH_POOLS_CONNECTORS = Object.entries(CLMM_REGISTRY) + .filter(([, ops]) => ops.fetchPools) + .map(([name]) => name); + +/** Routers that price an indicative (non-executable) quote on request. */ +export const INDICATIVE_PRICE_CONNECTORS = ['0x']; + +/** Solana routers accept `approximateIfNoExactOut`; every other connector ignores it. */ +export const APPROXIMATE_IF_NO_EXACT_OUT_CONNECTORS = Object.entries(ROUTER_REGISTRY) + .filter(([, ops]) => ops.chain === 'solana') + .map(([name]) => name); + +function lookup( + registry: Record, + connector: string, + type: TradingType, + chain: string, +): T { + const ops = registry[connector]; + if (!ops) { + throw httpErrors.badRequest( + `Connector '${connector}' has no ${type} support. Supported: ${Object.keys(registry).join(', ')}`, + ); + } + if (ops.chain !== chain) { + throw httpErrors.badRequest( + `Connector '${connector}' runs on ${ops.chain}, not ${chain}. Use a ${chain} ${type} connector: ` + + Object.entries(registry) + .filter(([, o]) => o.chain === chain) + .map(([n]) => n) + .join(', '), + ); + } + return ops; +} + +export const getRouterOps = (connector: string, chain: string): RouterOps => + lookup(ROUTER_REGISTRY, connector, 'router', chain); + +export const getClmmOps = (connector: string, chain: string): PoolOps => + lookup(CLMM_REGISTRY, connector, 'clmm', chain); + +export const getAmmOps = (connector: string, chain: string): PoolOps => lookup(AMM_REGISTRY, connector, 'amm', chain); + +/** Pool-scoped ops for a type that carries them (`clmm` or `amm`). */ +export const getPoolOps = (connector: string, chain: string, type: 'clmm' | 'amm'): PoolOps => + type === 'clmm' ? getClmmOps(connector, chain) : getAmmOps(connector, chain); + +/** + * Reject a connector that does not run on the chain the caller named. + * + * The pool-scoped swap routes get this check for free: they fetch their ops through + * `lookup`, which compares the two. The liquidity routes have no ops to fetch — they + * call their connector module directly — so nothing compared them, and the chain half + * of `chainNetwork` was decorative. `ethereum-mainnet` with a Solana connector ran that + * connector against network `mainnet`, and on a write that submits a transaction the + * caller never asked for. Same registry, same message as the swap routes. + */ +export const assertConnectorOnChain = (connector: string, chain: string, type: 'clmm' | 'amm'): void => { + lookup(type === 'clmm' ? CLMM_REGISTRY : AMM_REGISTRY, connector, type, chain); +}; + +export const getFetchPoolsOps = (connector: string, chain: string) => { + const ops = getClmmOps(connector, chain); + if (!ops.fetchPools) { + throw httpErrors.badRequest( + `Connector '${connector}' does not expose a pool-discovery API. ` + + `Supported: ${FETCH_POOLS_CONNECTORS.join(', ')}. Use /pools to list Gateway's configured pools instead.`, + ); + } + return ops.fetchPools; +}; diff --git a/src/trading/market-price.ts b/src/trading/market-price.ts new file mode 100644 index 0000000000..0386396bda --- /dev/null +++ b/src/trading/market-price.ts @@ -0,0 +1,99 @@ +/** + * A market price for a token pair, sourced through whichever swap provider the + * network is configured to use. + * + * Pool creation needs this: a new pool has no price of its own, so it is + * initialized against the price of existing venues. It lives outside the route + * modules because connectors call it (createPool), and routes must not be + * imported by the connectors they dispatch to. + */ +import { httpErrors } from '../services/error-handler'; + +import { parseChainNetwork, resolvePoolAddress, resolveSwapConnector } from './common'; +import { TradingType, getPoolOps, getRouterOps } from './connector-registry'; + +/** + * Quote a pair through a connector, defaulting to the network's configured + * swapProvider. `connector` accepts either a bare name plus an explicit type, or + * the config's "name/type" form. + */ +export async function getSwapQuote( + chainNetwork: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + options: { connector?: string; type?: TradingType; poolAddress?: string; slippagePct?: number } = {}, +): Promise { + const { chain, network } = parseChainNetwork(chainNetwork); + + // The type comes from the caller, from the connector string ("raydium/amm"), + // or from the configured swapProvider — in that order. + const typed = options.connector?.includes('/') ? (options.connector.split('/')[1] as TradingType) : undefined; + const type = options.type ?? typed ?? configuredType(chain, network); + const connector = resolveSwapConnector(chain, network, type, options.connector); + + if (type === 'router') { + return getRouterOps(connector, chain).quoteSwap({ + network, + baseToken, + quoteToken, + amount, + side, + slippagePct: options.slippagePct, + }); + } + + const poolAddress = await resolvePoolAddress( + chain, + network, + type, + connector, + baseToken, + quoteToken, + options.poolAddress, + ); + return getPoolOps(connector, chain, type).quoteSwap({ + network, + poolAddress, + baseToken, + side, + amount, + slippagePct: options.slippagePct, + }); +} + +function configuredType(chain: string, network: string): TradingType { + // resolveSwapConnector reads the same config; asking it for 'router' first and + // falling back keeps the provider-type discovery in one place. + for (const candidate of ['router', 'clmm', 'amm'] as TradingType[]) { + try { + resolveSwapConnector(chain, network, candidate); + return candidate; + } catch { + continue; + } + } + throw httpErrors.badRequest(`No swapProvider configured for ${chain}-${network}`); +} + +/** + * Current market price of `baseToken` in `quoteToken`, from a SELL quote of one + * base token. Throws a caller-friendly error when no route exists, since the + * usual remedy is to pass an explicit initial price. + */ +export async function getMarketPrice(chainNetwork: string, baseToken: string, quoteToken: string): Promise { + let quote: any; + try { + quote = await getSwapQuote(chainNetwork, baseToken, quoteToken, 1, 'SELL'); + } catch (e: any) { + throw httpErrors.badRequest( + `Could not fetch a market price for ${baseToken}/${quoteToken} to initialize the pool (${e.message}). ` + + 'Pass initialPrice explicitly.', + ); + } + if (!quote || !quote.amountIn || !quote.amountOut) { + throw httpErrors.badRequest(`No market route found for ${baseToken}/${quoteToken}. Pass initialPrice explicitly.`); + } + return quote.amountOut / quote.amountIn; // quote token per base token +} diff --git a/src/trading/pool-swap-routes.ts b/src/trading/pool-swap-routes.ts new file mode 100644 index 0000000000..4809fbe6e8 --- /dev/null +++ b/src/trading/pool-swap-routes.ts @@ -0,0 +1,247 @@ +/** + * Swap routes for the pool-scoped trading surfaces. + * + * /trading/clmm/quote-swap and /trading/amm/quote-swap (and their execute-swap + * counterparts) differ only in which registry they dispatch through and which + * connectors they accept, so both are built from one factory. Keeping them + * identical is the point: a caller moving between surfaces changes the path and + * nothing else. + */ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; + +import { ChainExecuteSwapResponseSchema, ChainQuoteSwapResponseSchema } from '../schemas/chain-schema'; +import { DecimalNumber } from '../schemas/decimal-field'; +import { logger } from '../services/logger'; +import { ensurePoolSaved, PoolFacts, recordQuietly } from '../services/token-pool-autosave'; + +import { getUnifiedPoolInfo } from './clmm/pools'; +import { + chainNetworkField, + connectorField, + parseChainNetwork, + poolAddressField, + rethrowRouteError, + resolvePoolAddress, + resolveSwapConnector, + slippagePctField, + walletAddressField, +} from './common'; +import { AMM_CONNECTORS, CLMM_CONNECTORS, getPoolOps } from './connector-registry'; +import { getAmmPoolInfo } from './trading-amm-routes/pool-info'; + +type PoolType = 'clmm' | 'amm'; + +/** + * Record the pool a swap ran against, and its two tokens, if Gateway does not know them. + * + * A swap reaches here with a pool address either because the caller pinned one — which + * they only need to do for a pool that is *not* in the configured list — or because it + * was resolved from that list, in which case this returns after one read. So the cost + * falls exactly on the case that has something to learn, and only once per pool. + */ +const learnPool = async ( + fastify: FastifyInstance, + type: PoolType, + chain: string, + network: string, + connector: string, + chainNetwork: string, + poolAddress: string, +): Promise => + recordQuietly( + ensurePoolSaved({ + chain, + network, + connector, + type, + poolAddress, + fetchPoolInfo: (): Promise => + type === 'clmm' + ? getUnifiedPoolInfo(fastify, connector, chainNetwork, poolAddress, 0) + : getAmmPoolInfo(connector, network, poolAddress), + }), + `pool ${poolAddress}`, + ); + +const connectorsFor = (type: PoolType) => (type === 'clmm' ? CLMM_CONNECTORS : AMM_CONNECTORS); + +const quoteSwapRequestSchema = (type: PoolType) => + Type.Object( + { + chainNetwork: chainNetworkField(), + connector: Type.Optional( + connectorField(connectorsFor(type), `${type.toUpperCase()} connector to price the swap against`), + ), + baseToken: Type.String({ description: 'Symbol or address of the base token', default: 'SOL' }), + quoteToken: Type.String({ description: 'Symbol or address of the quote token', default: 'USDC' }), + amount: Type.Number({ + format: 'decimal', + description: 'Amount of base token to trade', + default: 1, + }), + side: Type.String({ + description: 'BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + poolAddress: poolAddressField(), + slippagePct: slippagePctField(), + }, + { $id: type === 'amm' ? 'AmmQuoteSwapRequest' : 'ClmmQuoteSwapRequest' }, + ); + +const executeSwapRequestSchema = (type: PoolType) => + Type.Object( + { + chainNetwork: chainNetworkField(), + connector: Type.Optional( + connectorField(connectorsFor(type), `${type.toUpperCase()} connector to execute the swap against`), + ), + walletAddress: walletAddressField('Wallet address that will execute the swap'), + baseToken: Type.String({ description: 'Symbol or address of the base token', default: 'SOL' }), + quoteToken: Type.String({ description: 'Symbol or address of the quote token', default: 'USDC' }), + amount: Type.Number({ + format: 'decimal', + description: 'Amount of base token to trade', + default: 0.01, + }), + side: Type.String({ + description: 'BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + poolAddress: poolAddressField(), + slippagePct: slippagePctField(), + }, + { $id: type === 'amm' ? 'AmmExecuteSwapRequest' : 'ClmmExecuteSwapRequest' }, + ); + +/** + * One instance per pool type, because the `$id` above is what publishes these as spec + * components and Fastify rejects the same `$id` twice. Built here rather than inside the + * route factories so the route and the component registration share the object. + * + * The GET schema is published for the same reason as the POST one. Registering a schema + * and referencing it are independent: `addSchema` puts it in `components.schemas`, while + * @fastify/swagger still expands the querystring into `parameters` for the operation. So + * the component is emitted with the fields a caller actually sends, and a generated + * client gets a request model for the reads too — which is most of this API. + */ +export const QUOTE_SWAP_REQUEST_SCHEMAS = { + amm: quoteSwapRequestSchema('amm'), + clmm: quoteSwapRequestSchema('clmm'), +} as const; + +export const EXECUTE_SWAP_REQUEST_SCHEMAS = { + amm: executeSwapRequestSchema('amm'), + clmm: executeSwapRequestSchema('clmm'), +} as const; + +export const makeQuoteSwapRoute = (type: PoolType): FastifyPluginAsync => { + const schema = QUOTE_SWAP_REQUEST_SCHEMAS[type]; + + return async (fastify) => { + fastify.get( + '/quote-swap', + { + schema: { + description: `Get a swap quote from a single ${type.toUpperCase()} pool`, + tags: [`/trading/${type}`], + querystring: schema, + response: { 200: ChainQuoteSwapResponseSchema }, + }, + }, + async (request, reply) => { + const { chainNetwork, connector, baseToken, quoteToken, amount, side, poolAddress, slippagePct } = + request.query as Static>; + + try { + const { chain, network } = parseChainNetwork(chainNetwork); + const name = resolveSwapConnector(chain, network, type, connector); + const pool = await resolvePoolAddress(chain, network, type, name, baseToken, quoteToken, poolAddress); + + logger.info( + `[trading/${type}] quote ${baseToken}-${quoteToken} on ${chain}/${network} via ${name} pool ${pool}`, + ); + + const result = await getPoolOps(name, chain, type).quoteSwap({ + network, + poolAddress: pool, + baseToken, + side: side as 'BUY' | 'SELL', + amount, + slippagePct, + }); + await learnPool(fastify, type, chain, network, name, chainNetwork, pool); + + return reply.code(200).send(result); + } catch (e: any) { + rethrowRouteError(e, `Failed to get ${type} swap quote`); + } + }, + ); + }; +}; + +export const makeExecuteSwapRoute = (type: PoolType): FastifyPluginAsync => { + const schema = EXECUTE_SWAP_REQUEST_SCHEMAS[type]; + + return async (fastify) => { + fastify.post( + '/execute-swap', + { + schema: { + description: `Execute a swap against a single ${type.toUpperCase()} pool`, + tags: [`/trading/${type}`], + body: schema, + response: { 200: ChainExecuteSwapResponseSchema }, + }, + }, + async (request, reply) => { + const { + chainNetwork, + connector, + walletAddress, + baseToken, + quoteToken, + amount, + side, + poolAddress, + slippagePct, + } = request.body as Static>; + + try { + const { chain, network } = parseChainNetwork(chainNetwork); + const name = resolveSwapConnector(chain, network, type, connector); + const pool = await resolvePoolAddress(chain, network, type, name, baseToken, quoteToken, poolAddress); + + logger.info( + `[trading/${type}] execute ${side} ${amount} ${baseToken}-${quoteToken} on ${chain}/${network} via ${name} pool ${pool}`, + ); + + const result = await getPoolOps(name, chain, type).executeSwap({ + network, + walletAddress, + poolAddress: pool, + baseToken, + side: side as 'BUY' | 'SELL', + amount, + slippagePct, + }); + await learnPool(fastify, type, chain, network, name, chainNetwork, pool); + + // This route resolved exactly one pool, so name it in the confirmed result. + // Connectors report token flow but not the venue, which leaves a settled fill + // unattributable without refetching the transaction. Only meaningful once + // there is a `data` block — a pending swap has nothing to attribute yet. + return reply + .code(200) + .send(result.data ? { ...result, data: { ...result.data, poolAddress: pool } } : result); + } catch (e: any) { + rethrowRouteError(e, `Failed to execute ${type} swap`); + } + }, + ); + }; +}; diff --git a/src/trading/swap/execute.ts b/src/trading/swap/execute.ts deleted file mode 100644 index ea46dbe360..0000000000 --- a/src/trading/swap/execute.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -// Solana connector imports -import { getEthereumChainConfig, getEthereumNetworkConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig, getSolanaNetworkConfig } from '../../chains/solana/solana.config'; -import { executeSwap as zeroXRouterExecuteSwap } from '../../connectors/0x/router-routes/executeSwap'; -import { executeSwap as dflowRouterExecuteSwap } from '../../connectors/dflow/router-routes/executeSwap'; -import { executeSwap as jupiterRouterExecuteSwap } from '../../connectors/jupiter/router-routes/executeSwap'; -import { executeSwap as meteoraClmmExecuteSwap } from '../../connectors/meteora/clmm-routes/executeSwap'; -import { executeSwap as okxRouterExecuteSwap } from '../../connectors/okx/router-routes/executeSwap'; -import { executeSwap as orcaClmmExecuteSwap } from '../../connectors/orca/clmm-routes/executeSwap'; -import { executeSwap as pancakeswapAmmExecuteSwap } from '../../connectors/pancakeswap/amm-routes/executeSwap'; -import { executeSwap as pancakeswapClmmExecuteSwap } from '../../connectors/pancakeswap/clmm-routes/executeSwap'; -import { executeSwap as pancakeswapRouterExecuteSwap } from '../../connectors/pancakeswap/router-routes/executeSwap'; -import { executeSwap as pancakeswapSolClmmExecuteSwap } from '../../connectors/pancakeswap-sol/clmm-routes/executeSwap'; -import { executeSwap as raydiumAmmExecuteSwap } from '../../connectors/raydium/amm-routes/executeSwap'; -import { executeSwap as raydiumClmmExecuteSwap } from '../../connectors/raydium/clmm-routes/executeSwap'; -import { executeSwap as titanRouterExecuteSwap } from '../../connectors/titan/router-routes/executeSwap'; - -// Ethereum connector imports -import { executeSwap as uniswapAmmExecuteSwap } from '../../connectors/uniswap/amm-routes/executeSwap'; -import { executeSwap as uniswapClmmExecuteSwap } from '../../connectors/uniswap/clmm-routes/executeSwap'; -import { executeSwap as uniswapRouterExecuteSwap } from '../../connectors/uniswap/router-routes/executeSwap'; - -// Config and utilities -import { ChainExecuteSwapResponseSchema } from '../../schemas/chain-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; -import { PoolService } from '../../services/pool-service'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Unified swap execute request schema - * Accepts chain-network parameter like "solana-mainnet-beta", "ethereum-mainnet", or "ethereum-polygon" - */ -const UnifiedExecuteSwapRequestSchema = Type.Object({ - walletAddress: Type.String({ - description: 'Wallet address to execute swap from', - default: defaultWallet, - }), - chainNetwork: Type.String({ - description: - 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet, ethereum-polygon)', - default: 'solana-mainnet-beta', - }), - connector: Type.Optional( - Type.String({ - description: - "Connector to use in format: connector/type (e.g., jupiter/router, raydium/amm, uniswap/clmm). If not provided, uses network's configured swapProvider", - examples: ['jupiter/router'], - }), - ), - baseToken: Type.String({ - description: 'Symbol or address of the base token', - default: 'SOL', - }), - quoteToken: Type.String({ - description: 'Symbol or address of the quote token', - default: 'USDC', - }), - amount: Type.Number({ - description: 'Amount to swap', - default: 1, - }), - side: Type.String({ - description: 'Side of the swap', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - description: 'Slippage tolerance percentage (optional)', - default: 1, - }), - ), -}); - -type UnifiedExecuteSwapRequest = Static; - -/** - * Parse chain-network parameter into chain and network - * Examples: "solana-mainnet-beta" -> {chain: "solana", network: "mainnet-beta"} - * "ethereum-mainnet" -> {chain: "ethereum", network: "mainnet"} - * "ethereum-polygon" -> {chain: "ethereum", network: "polygon"} - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - // First part is always the chain - const chain = parts[0]; - - // Rest is the network (e.g., "mainnet-beta" from ["solana", "mainnet", "beta"]) - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - -/** - * Execute a Solana swap - */ -async function executeSolanaSwap( - network: string, - walletAddress: string, - baseToken: string, - quoteToken: string, - amount: number, - side: 'BUY' | 'SELL', - slippagePct?: number, - connector?: string, -): Promise { - try { - const networkConfig = getSolanaNetworkConfig(network); - - // Get swap provider from connector parameter or config (e.g., "jupiter/router", "raydium/amm", "meteora/clmm") - const swapProvider = connector || networkConfig.swapProvider || 'jupiter/router'; - const [connectorName, connectorType] = swapProvider.split('/'); - - logger.info( - `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, - ); - - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; - if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('solana', network, connectorType, baseToken, quoteToken, connectorName); - - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, - ); - } - - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); - } - - // Route to the appropriate connector based on swapProvider - const providerKey = swapProvider; - - if (providerKey === 'jupiter/router') { - return await jupiterRouterExecuteSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side, - slippagePct, - undefined, // priorityLevel - undefined, // maxLamports - ); - } else if (providerKey === 'dflow/router') { - return await dflowRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'okx/router') { - return await okxRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'titan/router') { - return await titanRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'raydium/amm') { - return await raydiumAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'raydium/clmm') { - return await raydiumClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'meteora/clmm') { - return await meteoraClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'pancakeswap-sol/clmm') { - return await pancakeswapSolClmmExecuteSwap( - network, - walletAddress, - poolAddress!, - baseToken, - side, - amount, - slippagePct, - ); - } else if (providerKey === 'orca/clmm') { - return await orcaClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); - } - - throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); - } catch (error) { - logger.error(`Error executing swap: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw httpErrors.internalServerError(`Failed to execute swap: ${error.message}`); - } -} - -/** - * Execute an Ethereum swap - */ -async function executeEthereumSwap( - network: string, - walletAddress: string, - baseToken: string, - quoteToken: string, - amount: number, - side: 'BUY' | 'SELL', - slippagePct?: number, - connector?: string, -): Promise { - try { - const networkConfig = getEthereumNetworkConfig(network); - - // Get swap provider from connector parameter or config (e.g., "uniswap/router", "uniswap/amm", "uniswap/clmm") - const swapProvider = connector || networkConfig.swapProvider || 'uniswap/router'; - const [connectorName, connectorType] = swapProvider.split('/'); - - logger.info( - `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, - ); - - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; - if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('ethereum', network, connectorType, baseToken, quoteToken, connectorName); - - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, - ); - } - - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); - } - - // Route to the appropriate connector based on swapProvider - const providerKey = swapProvider; - - if (providerKey === 'uniswap/router') { - return await uniswapRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'uniswap/amm') { - return await uniswapAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'uniswap/clmm') { - return await uniswapClmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'pancakeswap/router') { - return await pancakeswapRouterExecuteSwap( - walletAddress, - network, - baseToken, - quoteToken, - amount, - side, - slippagePct, - ); - } else if (providerKey === 'pancakeswap/amm') { - return await pancakeswapAmmExecuteSwap( - network, - walletAddress, - poolAddress!, - baseToken, - side, - amount, - slippagePct, - ); - } else if (providerKey === 'pancakeswap/clmm') { - return await pancakeswapClmmExecuteSwap( - network, - walletAddress, - poolAddress!, - baseToken, - side, - amount, - slippagePct, - ); - } else if (providerKey === '0x/router') { - return await zeroXRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); - } - - throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); - } catch (error) { - logger.error(`Error executing swap: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw httpErrors.internalServerError(`Failed to execute swap: ${error.message}`); - } -} - -/** - * Execute a swap across any supported chain - */ -export async function executeUnifiedSwap( - chainNetwork: string, - walletAddress: string, - baseToken: string, - quoteToken: string, - amount: number, - side: 'BUY' | 'SELL', - slippagePct?: number, - connector?: string, -): Promise { - const { chain, network } = parseChainNetwork(chainNetwork); - - logger.info( - `[UnifiedSwap] Executing swap for ${baseToken}-${quoteToken} on ${chain}/${network}${connector ? ` using ${connector}` : ''}`, - ); - - switch (chain.toLowerCase()) { - case 'ethereum': - return executeEthereumSwap(network, walletAddress, baseToken, quoteToken, amount, side, slippagePct, connector); - - case 'solana': - return executeSolanaSwap(network, walletAddress, baseToken, quoteToken, amount, side, slippagePct, connector); - - default: - throw httpErrors.badRequest(`Unsupported chain: ${chain}`); - } -} - -/** - * Unified swap execute route plugin - * POST /execute - */ -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post( - '/execute', - { - schema: { - description: 'Execute a swap on any supported chain', - tags: ['/trading/swap'], - body: UnifiedExecuteSwapRequestSchema, - response: { - 200: ChainExecuteSwapResponseSchema, - }, - }, - }, - async (request, reply) => { - const { chainNetwork, walletAddress, baseToken, quoteToken, amount, side, slippagePct, connector } = - request.body as UnifiedExecuteSwapRequest; - - try { - const result = await executeUnifiedSwap( - chainNetwork, - walletAddress, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - connector, - ); - return reply.code(200).send(result); - } catch (error: any) { - logger.error(`[UnifiedSwap] Execute error: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw fastify.httpErrors.internalServerError(error.message || 'Failed to execute swap'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/trading/swap/quote.ts b/src/trading/swap/quote.ts deleted file mode 100644 index db4953c5a5..0000000000 --- a/src/trading/swap/quote.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -// Solana connector imports -import { getEthereumNetworkConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaNetworkConfig } from '../../chains/solana/solana.config'; -import { quoteSwap as zeroXRouterQuoteSwap } from '../../connectors/0x/router-routes/quoteSwap'; -import { quoteSwap as dflowRouterQuoteSwap } from '../../connectors/dflow/router-routes/quoteSwap'; -import { quoteSwap as jupiterRouterQuoteSwap } from '../../connectors/jupiter/router-routes/quoteSwap'; -import { quoteSwap as meteoraClmmQuoteSwap } from '../../connectors/meteora/clmm-routes/quoteSwap'; -import { quoteSwap as okxRouterQuoteSwap } from '../../connectors/okx/router-routes/quoteSwap'; -import { quoteSwap as orcaClmmQuoteSwap } from '../../connectors/orca/clmm-routes/quoteSwap'; -import { quoteSwap as pancakeswapAmmQuoteSwap } from '../../connectors/pancakeswap/amm-routes/quoteSwap'; -import { quoteSwap as pancakeswapClmmQuoteSwap } from '../../connectors/pancakeswap/clmm-routes/quoteSwap'; -import { quoteSwap as pancakeswapRouterQuoteSwap } from '../../connectors/pancakeswap/router-routes/quoteSwap'; -import { quoteSwap as pancakeswapSolClmmQuoteSwap } from '../../connectors/pancakeswap-sol/clmm-routes/quoteSwap'; -import { quoteSwap as raydiumAmmQuoteSwap } from '../../connectors/raydium/amm-routes/quoteSwap'; -import { quoteSwap as raydiumClmmQuoteSwap } from '../../connectors/raydium/clmm-routes/quoteSwap'; -import { quoteSwap as titanRouterQuoteSwap } from '../../connectors/titan/router-routes/quoteSwap'; - -// Ethereum connector imports -import { quoteSwap as uniswapAmmQuoteSwap } from '../../connectors/uniswap/amm-routes/quoteSwap'; -import { quoteSwap as uniswapClmmQuoteSwap } from '../../connectors/uniswap/clmm-routes/quoteSwap'; -import { quoteSwap as uniswapRouterQuoteSwap } from '../../connectors/uniswap/router-routes/quoteSwap'; - -// Config and utilities -import { ChainQuoteSwapResponseSchema } from '../../schemas/chain-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; -import { PoolService } from '../../services/pool-service'; - -/** - * Unified swap quote request schema - * Accepts chain-network parameter like "solana-mainnet-beta", "ethereum-mainnet", or "ethereum-polygon" - */ -const UnifiedQuoteSwapRequestSchema = Type.Object({ - chainNetwork: Type.String({ - description: - 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet, ethereum-polygon)', - default: 'solana-mainnet-beta', - }), - connector: Type.Optional( - Type.String({ - description: - "Connector to use in format: connector/type (e.g., jupiter/router, raydium/amm, uniswap/clmm). If not provided, uses network's configured swapProvider", - examples: ['jupiter/router'], - }), - ), - baseToken: Type.String({ - description: 'Symbol or address of the base token', - default: 'SOL', - }), - quoteToken: Type.String({ - description: 'Symbol or address of the quote token', - default: 'USDC', - }), - amount: Type.Number({ - description: 'Amount to swap', - default: 1, - }), - side: Type.String({ - description: 'Side of the swap', - enum: ['BUY', 'SELL'], - default: 'SELL', - }), - slippagePct: Type.Optional( - Type.Number({ - description: 'Slippage tolerance percentage (optional)', - default: 1, - }), - ), -}); - -type UnifiedQuoteSwapRequest = Static; - -/** - * Parse chain-network parameter into chain and network - * Examples: "solana-mainnet-beta" -> {chain: "solana", network: "mainnet-beta"} - * "ethereum-mainnet" -> {chain: "ethereum", network: "mainnet"} - * "ethereum-polygon" -> {chain: "ethereum", network: "polygon"} - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw httpErrors.badRequest( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - // First part is always the chain - const chain = parts[0]; - - // Rest is the network (e.g., "mainnet-beta" from ["solana", "mainnet", "beta"]) - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - -/** - * Get a Solana swap quote - */ -async function getSolanaQuoteSwap( - network: string, - baseToken: string, - quoteToken: string, - amount: number, - side: 'BUY' | 'SELL', - slippagePct?: number, - connector?: string, -): Promise { - try { - const networkConfig = getSolanaNetworkConfig(network); - - // Get swap provider from connector parameter or config (e.g., "jupiter/router", "raydium/amm", "meteora/clmm") - const swapProvider = connector || networkConfig.swapProvider || 'jupiter/router'; - const [connectorName, connectorType] = swapProvider.split('/'); - - logger.info( - `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, - ); - - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; - if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('solana', network, connectorType, baseToken, quoteToken, connectorName); - - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, - ); - } - - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); - } - - // Route to the appropriate connector based on swapProvider - const providerKey = swapProvider; - - if (providerKey === 'jupiter/router') { - return await jupiterRouterQuoteSwap( - network, - baseToken, - quoteToken, - amount, - side, - slippagePct, - undefined, // onlyDirectRoutes - undefined, // restrictIntermediateTokens - ); - } else if (providerKey === 'dflow/router') { - return await dflowRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'okx/router') { - return await okxRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'titan/router') { - return await titanRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'raydium/amm') { - return await raydiumAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'raydium/clmm') { - return await raydiumClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'meteora/clmm') { - return await meteoraClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'pancakeswap-sol/clmm') { - return await pancakeswapSolClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'orca/clmm') { - return await orcaClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } - - throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); - } catch (error) { - logger.error(`Error getting swap quote: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw httpErrors.internalServerError(`Failed to get swap quote: ${error.message}`); - } -} - -/** - * Get an Ethereum swap quote - */ -async function getEthereumQuoteSwap( - network: string, - baseToken: string, - quoteToken: string, - amount: number, - side: 'BUY' | 'SELL', - slippagePct?: number, - connector?: string, -): Promise { - try { - const networkConfig = getEthereumNetworkConfig(network); - - // Get swap provider from connector parameter or config (e.g., "uniswap/router", "pancakeswap/router", "uniswap/amm", "uniswap/clmm") - const swapProvider = connector || networkConfig.swapProvider || 'uniswap/router'; - const [connectorName, connectorType] = swapProvider.split('/'); - - logger.info( - `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, - ); - - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; - if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('ethereum', network, connectorType, baseToken, quoteToken, connectorName); - - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, - ); - } - - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); - } - - // Route to the appropriate connector based on swapProvider - const providerKey = swapProvider; - - if (providerKey === 'uniswap/router') { - return await uniswapRouterQuoteSwap(network, undefined, baseToken, quoteToken, amount, side, slippagePct || 1); - } else if (providerKey === 'uniswap/amm') { - return await uniswapAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'uniswap/clmm') { - return await uniswapClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'pancakeswap/router') { - return await pancakeswapRouterQuoteSwap(network, undefined, baseToken, quoteToken, amount, side, slippagePct); - } else if (providerKey === 'pancakeswap/amm') { - return await pancakeswapAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === 'pancakeswap/clmm') { - return await pancakeswapClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); - } else if (providerKey === '0x/router') { - return await zeroXRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct || 1); - } - - throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); - } catch (error) { - logger.error(`Error getting swap quote: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw httpErrors.internalServerError(`Failed to get swap quote: ${error.message}`); - } -} - -/** - * Get a swap quote across any supported chain - */ -export async function getUnifiedQuoteSwap( - chainNetwork: string, - baseToken: string, - quoteToken: string, - amount: number, - side: 'BUY' | 'SELL', - slippagePct?: number, - connector?: string, -): Promise { - const { chain, network } = parseChainNetwork(chainNetwork); - - logger.info( - `[UnifiedSwap] Getting quote for ${baseToken}-${quoteToken} on ${chain}/${network}${connector ? ` using ${connector}` : ''}`, - ); - - switch (chain.toLowerCase()) { - case 'ethereum': - return getEthereumQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct, connector); - - case 'solana': - return getSolanaQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct, connector); - - default: - throw httpErrors.badRequest(`Unsupported chain: ${chain}`); - } -} - -/** - * Unified swap quote route plugin - * GET /quote - */ -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get( - '/quote', - { - schema: { - description: 'Get a swap quote for any supported chain', - tags: ['/trading/swap'], - querystring: UnifiedQuoteSwapRequestSchema, - response: { - 200: ChainQuoteSwapResponseSchema, - }, - }, - }, - async (request, reply) => { - const { chainNetwork, baseToken, quoteToken, amount, side, slippagePct, connector } = - request.query as UnifiedQuoteSwapRequest; - - try { - const result = await getUnifiedQuoteSwap( - chainNetwork, - baseToken, - quoteToken, - amount, - side as 'BUY' | 'SELL', - slippagePct, - connector, - ); - return reply.code(200).send(result); - } catch (error: any) { - logger.error(`[UnifiedSwap] Quote error: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw fastify.httpErrors.internalServerError(error.message || 'Failed to get swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/trading/trading-amm-routes/add-liquidity.ts b/src/trading/trading-amm-routes/add-liquidity.ts deleted file mode 100644 index 6b02ee97d6..0000000000 --- a/src/trading/trading-amm-routes/add-liquidity.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -import { addLiquidity as meteoraAddLiquidity } from '../../connectors/meteora/amm-routes/addLiquidity'; -import { addLiquidity as pancakeswapAddLiquidity } from '../../connectors/pancakeswap/amm-routes/addLiquidity'; -import { addLiquidity as raydiumAddLiquidity } from '../../connectors/raydium/amm-routes/addLiquidity'; -import { addLiquidity as uniswapAddLiquidity } from '../../connectors/uniswap/amm-routes/addLiquidity'; -import { AddLiquidityResponse, AddLiquidityResponseType } from '../../schemas/amm-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; - -const UnifiedAmmAddLiquidityRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), - poolAddress: Type.String({ description: 'Pool contract address' }), - baseTokenAmount: Type.Number({ description: 'Amount of base token to add' }), - quoteTokenAmount: Type.Number({ description: 'Amount of quote token to add' }), - positionAddress: Type.Optional( - Type.String({ - description: - 'meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new ' + - 'position. Ignored by fungible-LP AMMs.', - }), - ), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), -}); - -export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: AddLiquidityResponseType; - }>( - '/add-liquidity', - { - schema: { - description: 'Add liquidity to an AMM pool from any supported connector', - tags: ['/trading/amm'], - body: UnifiedAmmAddLiquidityRequest, - response: { 200: AddLiquidityResponse }, - }, - }, - async (request) => { - try { - const { - connector, - chainNetwork, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - positionAddress, - slippagePct, - } = request.body; - const { network } = parseChainNetwork(chainNetwork); - switch (connector) { - case 'meteora': - return await meteoraAddLiquidity( - network, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - positionAddress, - ); - case 'raydium': - return await raydiumAddLiquidity( - network, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - case 'uniswap': - return await uniswapAddLiquidity( - network, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - case 'pancakeswap': - return await pancakeswapAddLiquidity( - network, - walletAddress, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } - } catch (e: any) { - logger.error('Failed to add AMM liquidity:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to add liquidity'); - } - }, - ); -}; - -export default addLiquidityRoute; diff --git a/src/trading/trading-amm-routes/add.ts b/src/trading/trading-amm-routes/add.ts new file mode 100644 index 0000000000..27f4ac4ab9 --- /dev/null +++ b/src/trading/trading-amm-routes/add.ts @@ -0,0 +1,129 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { addLiquidity as meteoraAddLiquidity } from '../../connectors/meteora/amm-routes/addLiquidity'; +import { addLiquidity as pancakeswapAddLiquidity } from '../../connectors/pancakeswap/amm-routes/addLiquidity'; +import { addLiquidity as raydiumAddLiquidity } from '../../connectors/raydium/amm-routes/addLiquidity'; +import { addLiquidity as uniswapAddLiquidity } from '../../connectors/uniswap/amm-routes/addLiquidity'; +import { AddLiquidityResponse, AddLiquidityResponseType } from '../../schemas/amm-schema'; +import { httpErrors } from '../../services/error-handler'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + withIdentifiers, + slippagePctField, +} from '../common'; + +export const UnifiedAmmAddLiquidityRequest = Type.Object( + { + connector: connectorField(AMM_CONNECTORS, 'AMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), + poolAddress: Type.String({ description: 'Pool contract address' }), + baseTokenAmount: Type.Number({ + format: 'decimal', + description: 'Amount of base token to add', + }), + quoteTokenAmount: Type.Number({ + format: 'decimal', + description: 'Amount of quote token to add', + }), + positionAddress: Type.Optional( + Type.String({ + 'x-connectors': ['meteora'], + description: + 'meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new ' + + 'position. Ignored by fungible-LP AMMs.', + }), + ), + slippagePct: slippagePctField(), + }, + { $id: 'AmmAddRequest', additionalProperties: false }, +); + +export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: AddLiquidityResponseType; + }>( + '/add', + { + schema: { + description: 'Add liquidity to an AMM pool from any supported connector', + tags: ['/trading/amm'], + body: UnifiedAmmAddLiquidityRequest, + response: { 200: AddLiquidityResponse }, + }, + }, + async (request) => { + try { + const { + connector, + chainNetwork, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + positionAddress, + slippagePct, + } = request.body; + const { network } = resolveChainNetwork(chainNetwork, connector, 'amm'); + const result = await (async () => { + switch (connector) { + case 'meteora': + return await meteoraAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + positionAddress, + ); + case 'raydium': + return await raydiumAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + case 'uniswap': + return await uniswapAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + case 'pancakeswap': + return await pancakeswapAddLiquidity( + network, + walletAddress, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + })(); + + return withIdentifiers(result, { poolAddress, positionAddress }); + } catch (e: any) { + rethrowRouteError(e, 'Failed to add AMM liquidity'); + } + }, + ); +}; + +export default addLiquidityRoute; diff --git a/src/trading/trading-amm-routes/common.ts b/src/trading/trading-amm-routes/common.ts deleted file mode 100644 index a0e702f621..0000000000 --- a/src/trading/trading-amm-routes/common.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -/** AMM connectors that back the unified /trading/amm routes. */ -export const AMM_CONNECTORS = ['meteora', 'raydium', 'uniswap', 'pancakeswap']; - -/** Parse a chain-network string (e.g. "solana-mainnet-beta") into its chain and network parts. */ -export function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - return { chain: parts[0], network: parts.slice(1).join('-') }; -} - -// Default wallet from Solana config, falling back to Ethereum when Solana is unavailable. -let dw: string; -try { - dw = getSolanaChainConfig().defaultWallet; -} catch { - dw = getEthereumChainConfig().defaultWallet; -} -export const defaultWallet = dw; diff --git a/src/trading/trading-amm-routes/create-pool.ts b/src/trading/trading-amm-routes/create-pool.ts index a72e8c4b24..7023961cf5 100644 --- a/src/trading/trading-amm-routes/create-pool.ts +++ b/src/trading/trading-amm-routes/create-pool.ts @@ -1,88 +1,59 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { createPool as meteoraCreatePool } from '../../connectors/meteora/amm-routes/createPool'; import { createPool as pancakeswapCreatePool } from '../../connectors/pancakeswap/amm-routes/createPool'; import { createPool as raydiumCreatePool } from '../../connectors/raydium/amm-routes/createPool'; import { createPool as uniswapCreatePool } from '../../connectors/uniswap/amm-routes/createPool'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; +import { CreatePoolRequest, CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network. - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - return { chain: parts[0], network: parts.slice(1).join('-') }; -} - -// Unified schema with a connector field. Per-connector create-pool extras are optional -// and only consumed by their owning connector (configAddress → meteora, feeConfigIndex → -// raydium, gasPrice/maxGas/slippagePct → uniswap). See docs/connectors/meteora-damm-v2.md. -const UnifiedCreatePoolRequest = Type.Object({ - connector: Type.String({ - description: 'AMM connector name (meteora, raydium, uniswap)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.String({ - description: 'Wallet address (pool creator + payer)', - default: defaultWallet, - }), - baseToken: Type.String({ description: 'Base token symbol or address (becomes the pool base)' }), - quoteToken: Type.String({ description: 'Quote token symbol or address (becomes the pool quote)' }), - baseTokenAmount: Type.Number({ description: 'Amount of base token to seed the pool with' }), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: - 'Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. ' + - 'If omitted (and no initialPrice), the price is fetched from the market.', +// Composed from the canonical CreatePoolRequest (schemas/amm-schema.ts): the +// unified route swaps per-connector `network` for connector + chainNetwork, +// defaults the wallet, and adds the per-protocol fee-config selectors. +export const UnifiedCreatePoolRequest = Type.Composite( + [ + Type.Object({ + connector: connectorField(AMM_CONNECTORS, 'AMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address (pool creator + payer)', + default: defaultWallet, + }), }), - ), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current ' + - 'market price is fetched from the unified swap router so the pool opens on-market.', + Type.Omit(CreatePoolRequest, ['network', 'walletAddress'], {}), + // Optional per-protocol fee-config selectors, last so required fields lead the schema: + Type.Object({ + configAddress: Type.Optional( + Type.String({ + 'x-connectors': ['meteora'], + description: + 'Meteora DAMM v2 config account address (required for the meteora connector — configs are ' + + 'permissionless accounts with no index derivation, so the address must be explicit).', + }), + ), + ammConfigIndex: Type.Optional( + Type.Number({ + 'x-connectors': ['raydium'], + description: 'Raydium CPMM fee-config index (optional; defaults to the first available config).', + }), + ), + slippagePct: slippagePctField( + "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + ), }), - ), - // Connector-specific create-pool params (optional; ignored by connectors that do not use them): - configAddress: Type.Optional( - Type.String({ description: 'Meteora DAMM v2 config account address (required for the meteora connector)' }), - ), - feeConfigIndex: Type.Optional( - Type.Number({ description: 'Raydium CPMM fee config index (optional; defaults to the first available config)' }), - ), - openTime: Type.Optional(Type.Number({ description: 'Raydium CPMM pool open time (unix seconds; optional)' })), - gasPrice: Type.Optional(Type.Number({ description: 'Uniswap (EVM) gas price in gwei (optional)' })), - maxGas: Type.Optional(Type.Number({ description: 'Uniswap (EVM) max gas limit (optional)' })), - slippagePct: Type.Optional( - Type.Number({ minimum: 0, maximum: 100, description: 'Uniswap seeding slippage percentage (optional)' }), - ), -}); + ], + { $id: 'AmmCreatePoolRequest', additionalProperties: false }, +); export const createPoolRoute: FastifyPluginAsync = async (fastify) => { fastify.post<{ @@ -93,7 +64,8 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { { schema: { description: - 'Create and seed a new AMM pool across supported connectors (Meteora DAMM v2, Raydium CPMM, Uniswap V2)', + 'Create and seed a new AMM pool across supported connectors (Meteora DAMM v2, Raydium CPMM, ' + + 'Uniswap V2, PancakeSwap V2)', tags: ['/trading/amm'], body: UnifiedCreatePoolRequest, response: { @@ -113,14 +85,11 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, initialPrice, configAddress, - feeConfigIndex, - openTime, - gasPrice, - maxGas, + ammConfigIndex, slippagePct, } = request.body; - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'amm'); switch (connector) { case 'meteora': @@ -144,8 +113,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - feeConfigIndex, - openTime, + ammConfigIndex, ); case 'uniswap': @@ -157,8 +125,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - gasPrice, - maxGas, slippagePct, ); @@ -171,8 +137,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - gasPrice, - maxGas, slippagePct, ); @@ -180,9 +144,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { throw httpErrors.badRequest(`Unsupported AMM connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to create pool:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to create pool'); + rethrowRouteError(e, 'Failed to create pool'); } }, ); diff --git a/src/trading/trading-amm-routes/execute-swap.ts b/src/trading/trading-amm-routes/execute-swap.ts deleted file mode 100644 index bbdceb1912..0000000000 --- a/src/trading/trading-amm-routes/execute-swap.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -import { executeSwap as meteoraExecuteSwap } from '../../connectors/meteora/amm-routes/executeSwap'; -import { executeSwap as pancakeswapExecuteSwap } from '../../connectors/pancakeswap/amm-routes/executeSwap'; -import { executeSwap as raydiumExecuteSwap } from '../../connectors/raydium/amm-routes/executeSwap'; -import { executeSwap as uniswapExecuteSwap } from '../../connectors/uniswap/amm-routes/executeSwap'; -import { ExecuteSwapResponse, ExecuteSwapResponseType } from '../../schemas/amm-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; - -const UnifiedAmmExecuteSwapRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), - poolAddress: Type.String({ description: 'Pool contract address' }), - baseToken: Type.String({ description: 'Base token symbol or address (determines swap direction)' }), - amount: Type.Number({ description: 'Amount denominated in the base token' }), - side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'] }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), -}); - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap against a specific AMM pool from any supported connector', - tags: ['/trading/amm'], - body: UnifiedAmmExecuteSwapRequest, - response: { 200: ExecuteSwapResponse }, - }, - }, - async (request) => { - try { - const { connector, chainNetwork, walletAddress, poolAddress, baseToken, amount, side, slippagePct } = - request.body; - const { network } = parseChainNetwork(chainNetwork); - const s = side as 'BUY' | 'SELL'; - switch (connector) { - case 'meteora': - return await meteoraExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - case 'raydium': - return await raydiumExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - case 'uniswap': - return await uniswapExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - case 'pancakeswap': - return await pancakeswapExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } - } catch (e: any) { - logger.error('Failed to execute AMM swap:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to execute swap'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/trading/trading-amm-routes/index.ts b/src/trading/trading-amm-routes/index.ts index 9b621f1548..b6a55908a2 100644 --- a/src/trading/trading-amm-routes/index.ts +++ b/src/trading/trading-amm-routes/index.ts @@ -2,8 +2,21 @@ export { createPoolRoute } from './create-pool'; export { poolInfoRoute } from './pool-info'; export { positionInfoRoute } from './position-info'; export { positionsOwnedRoute } from './positions-owned'; -export { quoteSwapRoute } from './quote-swap'; -export { executeSwapRoute } from './execute-swap'; export { quoteLiquidityRoute } from './quote-liquidity'; -export { addLiquidityRoute } from './add-liquidity'; -export { removeLiquidityRoute } from './remove-liquidity'; +export { addLiquidityRoute } from './add'; +export { removeLiquidityRoute } from './remove'; + +// The request bodies, re-exported so app.ts can publish them as spec components. Only +// the POSTs appear here: the GET routes carry their fields as `parameters`, which never +// enter components.schemas. +export { UnifiedCreatePoolRequest } from './create-pool'; +export { UnifiedAmmAddLiquidityRequest } from './add'; +export { UnifiedAmmRemoveLiquidityRequest } from './remove'; + +// The GET querystrings. Registering these publishes them as components; the routes still +// expand their fields into `parameters`, so the operations are unchanged and a generated +// client gains a request model for the reads. +export { UnifiedAmmPoolInfoRequest } from './pool-info'; +export { UnifiedAmmPositionInfoRequest } from './position-info'; +export { UnifiedAmmPositionsOwnedRequest } from './positions-owned'; +export { UnifiedAmmQuoteLiquidityRequest } from './quote-liquidity'; diff --git a/src/trading/trading-amm-routes/pool-info.ts b/src/trading/trading-amm-routes/pool-info.ts index 2f553eb89b..b2eb9e11f2 100644 --- a/src/trading/trading-amm-routes/pool-info.ts +++ b/src/trading/trading-amm-routes/pool-info.ts @@ -7,18 +7,33 @@ import { getPoolInfo as raydiumGetPoolInfo } from '../../connectors/raydium/amm- import { getPoolInfo as uniswapGetPoolInfo } from '../../connectors/uniswap/amm-routes/poolInfo'; import { PoolInfo, PoolInfoSchema } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; +import { ensurePoolSaved, recordQuietly } from '../../services/token-pool-autosave'; +import { AMM_CONNECTORS, chainNetworkField, connectorField, resolveChainNetwork, rethrowRouteError } from '../common'; -import { AMM_CONNECTORS, parseChainNetwork } from './common'; +export const UnifiedAmmPoolInfoRequest = Type.Object( + { + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), + poolAddress: Type.String({ description: 'Pool contract address' }), + }, + { $id: 'AmmPoolInfoRequest', additionalProperties: false }, +); -const UnifiedAmmPoolInfoRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - poolAddress: Type.String({ description: 'Pool contract address' }), -}); +/** Pool info from any AMM connector. Exported so the swap routes can learn a pool too. */ +export async function getAmmPoolInfo(connector: string, network: string, poolAddress: string) { + switch (connector) { + case 'meteora': + return await meteoraGetPoolInfo(network, poolAddress); + case 'raydium': + return await raydiumGetPoolInfo(network, poolAddress); + case 'uniswap': + return await uniswapGetPoolInfo(network, poolAddress); + case 'pancakeswap': + return await pancakeswapGetPoolInfo(network, poolAddress); + default: + throw httpErrors.badRequest(`Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`); + } +} export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ @@ -37,25 +52,27 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const { connector, chainNetwork, poolAddress } = request.query; - const { network } = parseChainNetwork(chainNetwork); - switch (connector) { - case 'meteora': - return await meteoraGetPoolInfo(network, poolAddress); - case 'raydium': - return await raydiumGetPoolInfo(network, poolAddress); - case 'uniswap': - return await uniswapGetPoolInfo(network, poolAddress); - case 'pancakeswap': - return await pancakeswapGetPoolInfo(network, poolAddress); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } + const { chain, network } = resolveChainNetwork(chainNetwork, connector, 'amm'); + const poolInfo = await getAmmPoolInfo(connector, network, poolAddress); + + // Asking about a pool by address is the moment Gateway can learn it: the reply + // already carries both token addresses and the fee, so recording it costs the + // list read below and nothing more when it is already known. + await recordQuietly( + ensurePoolSaved({ + chain, + network, + connector, + type: 'amm', + poolAddress, + fetchPoolInfo: async () => poolInfo, + }), + `pool ${poolAddress}`, + ); + + return poolInfo; } catch (e: any) { - logger.error('Failed to get AMM pool info:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to get pool info'); + rethrowRouteError(e, 'Failed to get AMM pool info'); } }, ); diff --git a/src/trading/trading-amm-routes/position-info.ts b/src/trading/trading-amm-routes/position-info.ts index 8cde9b4c73..d7c5f4b3a3 100644 --- a/src/trading/trading-amm-routes/position-info.ts +++ b/src/trading/trading-amm-routes/position-info.ts @@ -7,19 +7,24 @@ import { getPositionInfo as raydiumGetPositionInfo } from '../../connectors/rayd import { getPositionInfo as uniswapGetPositionInfo } from '../../connectors/uniswap/amm-routes/positionInfo'; import { PositionInfo, PositionInfoSchema } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, +} from '../common'; -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; - -const UnifiedAmmPositionInfoRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - poolAddress: Type.String({ description: 'Pool contract address' }), - walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), -}); +export const UnifiedAmmPositionInfoRequest = Type.Object( + { + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), + poolAddress: Type.String({ description: 'Pool contract address' }), + walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), + }, + { $id: 'AmmPositionInfoRequest', additionalProperties: false }, +); export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ @@ -38,7 +43,7 @@ export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const { connector, chainNetwork, poolAddress, walletAddress } = request.query; - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'amm'); switch (connector) { case 'meteora': return await meteoraGetPositionInfo(network, poolAddress, walletAddress); @@ -54,9 +59,7 @@ export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to get AMM position info:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to get position info'); + rethrowRouteError(e, 'Failed to get AMM position info'); } }, ); diff --git a/src/trading/trading-amm-routes/positions-owned.ts b/src/trading/trading-amm-routes/positions-owned.ts index 43aec08d84..71734459de 100644 --- a/src/trading/trading-amm-routes/positions-owned.ts +++ b/src/trading/trading-amm-routes/positions-owned.ts @@ -4,18 +4,23 @@ import { FastifyPluginAsync } from 'fastify'; import { getPositionsOwned as meteoraGetPositionsOwned } from '../../connectors/meteora/amm-routes/positionsOwned'; import { PositionInfo, PositionInfoSchema } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, +} from '../common'; -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; - -const UnifiedAmmPositionsOwnedRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta)', - default: 'solana-mainnet-beta', - }), - walletAddress: Type.String({ description: 'Wallet address to list positions for', default: defaultWallet }), -}); +export const UnifiedAmmPositionsOwnedRequest = Type.Object( + { + connector: connectorField(AMM_CONNECTORS, 'AMM connector (only non-fungible-LP AMMs supported: meteora)'), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ description: 'Wallet address to list positions for', default: defaultWallet }), + }, + { $id: 'AmmPositionsOwnedRequest', additionalProperties: false }, +); export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ @@ -37,7 +42,7 @@ export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const { connector, chainNetwork, walletAddress } = request.query; - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'amm'); switch (connector) { case 'meteora': return await meteoraGetPositionsOwned(fastify, network, walletAddress); @@ -54,9 +59,7 @@ export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to list AMM positions owned:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to list positions owned'); + rethrowRouteError(e, 'Failed to list AMM positions owned'); } }, ); diff --git a/src/trading/trading-amm-routes/quote-liquidity.ts b/src/trading/trading-amm-routes/quote-liquidity.ts index 49d073ad28..0fc189f689 100644 --- a/src/trading/trading-amm-routes/quote-liquidity.ts +++ b/src/trading/trading-amm-routes/quote-liquidity.ts @@ -7,21 +7,32 @@ import { quoteLiquidity as raydiumQuoteLiquidity } from '../../connectors/raydiu import { quoteLiquidity as uniswapQuoteLiquidity } from '../../connectors/uniswap/amm-routes/quoteLiquidity'; import { QuoteLiquidityResponse, QuoteLiquidityResponseType } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + resolveChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; -import { AMM_CONNECTORS, parseChainNetwork } from './common'; - -const UnifiedAmmQuoteLiquidityRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - poolAddress: Type.String({ description: 'Pool contract address' }), - baseTokenAmount: Type.Number({ description: 'Amount of base token to deposit' }), - quoteTokenAmount: Type.Number({ description: 'Amount of quote token to deposit' }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), -}); +export const UnifiedAmmQuoteLiquidityRequest = Type.Object( + { + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), + poolAddress: Type.String({ description: 'Pool contract address' }), + baseTokenAmount: Type.Number({ + format: 'decimal', + description: 'Amount of base token to deposit', + }), + quoteTokenAmount: Type.Number({ + format: 'decimal', + description: 'Amount of quote token to deposit', + }), + slippagePct: slippagePctField(), + }, + { $id: 'AmmQuoteLiquidityRequest', additionalProperties: false }, +); export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ @@ -40,31 +51,35 @@ export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const { connector, chainNetwork, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct } = request.query; - const { network } = parseChainNetwork(chainNetwork); - switch (connector) { - case 'meteora': - return await meteoraQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); - case 'raydium': - return await raydiumQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); - case 'uniswap': - return await uniswapQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); - case 'pancakeswap': - return await pancakeswapQuoteLiquidity( - network, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } + const { network } = resolveChainNetwork(chainNetwork, connector, 'amm'); + const quote = await (async () => { + switch (connector) { + case 'meteora': + return await meteoraQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + case 'raydium': + return await raydiumQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + case 'uniswap': + return await uniswapQuoteLiquidity(network, poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct); + case 'pancakeswap': + return await pancakeswapQuoteLiquidity( + network, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + })(); + + // Names the pool the split was computed against; on CLMM the caller need not + // have supplied one. + return { ...quote, poolAddress }; } catch (e: any) { - logger.error('Failed to quote AMM liquidity:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to quote liquidity'); + rethrowRouteError(e, 'Failed to quote AMM liquidity'); } }, ); diff --git a/src/trading/trading-amm-routes/quote-swap.ts b/src/trading/trading-amm-routes/quote-swap.ts deleted file mode 100644 index 72e467a6aa..0000000000 --- a/src/trading/trading-amm-routes/quote-swap.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -import { quoteSwap as meteoraQuoteSwap } from '../../connectors/meteora/amm-routes/quoteSwap'; -import { quoteSwap as pancakeswapQuoteSwap } from '../../connectors/pancakeswap/amm-routes/quoteSwap'; -import { quoteSwap as raydiumQuoteSwap } from '../../connectors/raydium/amm-routes/quoteSwap'; -import { quoteSwap as uniswapQuoteSwap } from '../../connectors/uniswap/amm-routes/quoteSwap'; -import { QuoteSwapResponse, QuoteSwapResponseType } from '../../schemas/amm-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork } from './common'; - -const UnifiedAmmQuoteSwapRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - poolAddress: Type.String({ description: 'Pool contract address' }), - baseToken: Type.String({ description: 'Base token symbol or address (determines swap direction)' }), - amount: Type.Number({ description: 'Amount denominated in the base token' }), - side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'] }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), -}); - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: Static; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get a swap quote against a specific AMM pool from any supported connector', - tags: ['/trading/amm'], - querystring: UnifiedAmmQuoteSwapRequest, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { connector, chainNetwork, poolAddress, baseToken, amount, side, slippagePct } = request.query; - const { network } = parseChainNetwork(chainNetwork); - const s = side as 'BUY' | 'SELL'; - switch (connector) { - case 'meteora': - return await meteoraQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - case 'raydium': - return await raydiumQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - case 'uniswap': - return await uniswapQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - case 'pancakeswap': - return await pancakeswapQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } - } catch (e: any) { - logger.error('Failed to get AMM swap quote:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to get swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/trading/trading-amm-routes/remove-liquidity.ts b/src/trading/trading-amm-routes/remove-liquidity.ts deleted file mode 100644 index f1c9aff3d5..0000000000 --- a/src/trading/trading-amm-routes/remove-liquidity.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -import { removeLiquidity as meteoraRemoveLiquidity } from '../../connectors/meteora/amm-routes/removeLiquidity'; -import { removeLiquidity as pancakeswapRemoveLiquidity } from '../../connectors/pancakeswap/amm-routes/removeLiquidity'; -import { removeLiquidity as raydiumRemoveLiquidity } from '../../connectors/raydium/amm-routes/removeLiquidity'; -import { removeLiquidity as uniswapRemoveLiquidity } from '../../connectors/uniswap/amm-routes/removeLiquidity'; -import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../schemas/amm-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; - -const UnifiedAmmRemoveLiquidityRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), - poolAddress: Type.String({ description: 'Pool contract address' }), - positionAddress: Type.Optional( - Type.String({ - description: - 'Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. ' + - 'List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.', - }), - ), - percentageToRemove: Type.Number({ minimum: 0, maximum: 100, description: 'Percentage of liquidity to remove' }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), -}); - -export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: RemoveLiquidityResponseType; - }>( - '/remove-liquidity', - { - schema: { - description: 'Remove liquidity from an AMM pool from any supported connector', - tags: ['/trading/amm'], - body: UnifiedAmmRemoveLiquidityRequest, - response: { 200: RemoveLiquidityResponse }, - }, - }, - async (request) => { - try { - const { - connector, - chainNetwork, - walletAddress, - poolAddress, - positionAddress, - percentageToRemove, - slippagePct, - } = request.body; - const { network } = parseChainNetwork(chainNetwork); - switch (connector) { - case 'meteora': - if (!positionAddress) { - throw httpErrors.badRequest( - 'positionAddress is required for meteora: DAMM v2 positions are NFTs and a wallet may hold ' + - 'several per pool. List them with position-info or positions-owned.', - ); - } - return await meteoraRemoveLiquidity( - network, - walletAddress, - poolAddress, - positionAddress, - percentageToRemove, - slippagePct, - ); - case 'raydium': - return await raydiumRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); - case 'uniswap': - return await uniswapRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); - case 'pancakeswap': - return await pancakeswapRemoveLiquidity( - network, - walletAddress, - poolAddress, - percentageToRemove, - slippagePct, - ); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } - } catch (e: any) { - logger.error('Failed to remove AMM liquidity:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to remove liquidity'); - } - }, - ); -}; - -export default removeLiquidityRoute; diff --git a/src/trading/trading-amm-routes/remove.ts b/src/trading/trading-amm-routes/remove.ts new file mode 100644 index 0000000000..a98c0ccf49 --- /dev/null +++ b/src/trading/trading-amm-routes/remove.ts @@ -0,0 +1,118 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { removeLiquidity as meteoraRemoveLiquidity } from '../../connectors/meteora/amm-routes/removeLiquidity'; +import { removeLiquidity as pancakeswapRemoveLiquidity } from '../../connectors/pancakeswap/amm-routes/removeLiquidity'; +import { removeLiquidity as raydiumRemoveLiquidity } from '../../connectors/raydium/amm-routes/removeLiquidity'; +import { removeLiquidity as uniswapRemoveLiquidity } from '../../connectors/uniswap/amm-routes/removeLiquidity'; +import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../schemas/amm-schema'; +import { httpErrors } from '../../services/error-handler'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + withIdentifiers, + slippagePctField, +} from '../common'; + +export const UnifiedAmmRemoveLiquidityRequest = Type.Object( + { + connector: connectorField(AMM_CONNECTORS, 'AMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), + poolAddress: Type.String({ description: 'Pool contract address' }), + positionAddress: Type.Optional( + Type.String({ + 'x-connectors': ['meteora'], + description: + 'Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. ' + + 'List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.', + }), + ), + percentageToRemove: Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + description: 'Percentage of liquidity to remove', + default: 100, + examples: [100], + }), + slippagePct: slippagePctField(), + }, + { $id: 'AmmRemoveRequest', additionalProperties: false }, +); + +export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: Static; + Reply: RemoveLiquidityResponseType; + }>( + '/remove', + { + schema: { + description: 'Remove liquidity from an AMM pool from any supported connector', + tags: ['/trading/amm'], + body: UnifiedAmmRemoveLiquidityRequest, + response: { 200: RemoveLiquidityResponse }, + }, + }, + async (request) => { + try { + const { + connector, + chainNetwork, + walletAddress, + poolAddress, + positionAddress, + percentageToRemove, + slippagePct, + } = request.body; + const { network } = resolveChainNetwork(chainNetwork, connector, 'amm'); + const result = await (async () => { + switch (connector) { + case 'meteora': + if (!positionAddress) { + throw httpErrors.badRequest( + 'positionAddress is required for meteora: DAMM v2 positions are NFTs and a wallet may hold ' + + 'several per pool. List them with position-info or positions-owned.', + ); + } + return await meteoraRemoveLiquidity( + network, + walletAddress, + poolAddress, + positionAddress, + percentageToRemove, + slippagePct, + ); + case 'raydium': + return await raydiumRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); + case 'uniswap': + return await uniswapRemoveLiquidity(network, walletAddress, poolAddress, percentageToRemove, slippagePct); + case 'pancakeswap': + return await pancakeswapRemoveLiquidity( + network, + walletAddress, + poolAddress, + percentageToRemove, + slippagePct, + ); + default: + throw httpErrors.badRequest( + `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, + ); + } + })(); + + return withIdentifiers(result, { poolAddress, positionAddress }); + } catch (e: any) { + rethrowRouteError(e, 'Failed to remove AMM liquidity'); + } + }, + ); +}; + +export default removeLiquidityRoute; diff --git a/src/trading/trading-clmm-routes/add.ts b/src/trading/trading-clmm-routes/add.ts index 7eddf422a0..c64abedeeb 100644 --- a/src/trading/trading-clmm-routes/add.ts +++ b/src/trading/trading-clmm-routes/add.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { addLiquidity as meteoraAddLiquidity } from '../../connectors/meteora/clmm-routes/addLiquidity'; import { addLiquidity as orcaAddLiquidity } from '../../connectors/orca/clmm-routes/addLiquidity'; import { addLiquidity as pancakeswapAddLiquidity } from '../../connectors/pancakeswap/clmm-routes/addLiquidity'; @@ -11,82 +9,62 @@ import { addLiquidity as raydiumAddLiquidity } from '../../connectors/raydium/cl import { addLiquidity as uniswapAddLiquidity } from '../../connectors/uniswap/clmm-routes/addLiquidity'; import { AddLiquidityResponseType, AddLiquidityResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + withIdentifiers, + slippagePctField, +} from '../common'; // Constants for examples (using Meteora CLMM values) const BASE_TOKEN_AMOUNT = 0.01; const QUOTE_TOKEN_AMOUNT = 2; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - // Unified schema with connector field -const UnifiedAddLiquidityRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.String({ - description: 'Wallet address', - default: defaultWallet, - }), - positionAddress: Type.String({ - description: 'Position address', - examples: [''], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit (omit for single-sided quote deposit)', - examples: [BASE_TOKEN_AMOUNT], +export const UnifiedAddLiquidityRequest = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address', + default: defaultWallet, }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit (omit for single-sided base deposit)', - examples: [QUOTE_TOKEN_AMOUNT], + positionAddress: Type.String({ + description: 'Position address', + examples: [''], }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: 1, - examples: [1], - }), - ), -}); + baseTokenAmount: Type.Optional( + Type.Number({ + format: 'decimal', + description: 'Amount of base token to deposit (omit for single-sided quote deposit)', + examples: [BASE_TOKEN_AMOUNT], + }), + ), + quoteTokenAmount: Type.Optional( + Type.Number({ + format: 'decimal', + description: 'Amount of quote token to deposit (omit for single-sided base deposit)', + examples: [QUOTE_TOKEN_AMOUNT], + }), + ), + slippagePct: slippagePctField(), + // Meteora-specific parameter (optional, ignored by other connectors). Without it an + // add falls back to the connector-config default shape, which can silently differ + // from the shape the position was opened with. + strategyType: Type.Optional( + Type.Number({ + 'x-connectors': ['meteora'], + description: 'Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.', + examples: [0], + }), + ), + }, + { $id: 'ClmmAddRequest', additionalProperties: false }, +); // Import connector functions @@ -116,10 +94,11 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, slippagePct, + strategyType, } = request.body; // Parse chain and network from chainNetwork parameter - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'clmm'); // Single-sided deposits are valid; the omitted side deposits 0. const baseAmount = baseTokenAmount ?? 0; @@ -129,76 +108,78 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { } // Route to appropriate connector - switch (connector) { - case 'uniswap': - return await uniswapAddLiquidity( - network, - walletAddress, - positionAddress, - baseAmount, - quoteAmount, - slippagePct, - ); - - case 'pancakeswap': - return await pancakeswapAddLiquidity( - network, - walletAddress, - positionAddress, - baseAmount, - quoteAmount, - slippagePct, - ); - - case 'raydium': - return await raydiumAddLiquidity( - network, - walletAddress, - positionAddress, - baseAmount, - quoteAmount, - slippagePct, - ); - - case 'meteora': - return await meteoraAddLiquidity( - network, - walletAddress, - positionAddress, - baseAmount, - quoteAmount, - slippagePct, - ); - - case 'pancakeswap-sol': - return await pancakeswapSolAddLiquidity( - network, - walletAddress, - positionAddress, - baseAmount, - quoteAmount, - slippagePct, - ); - - case 'orca': - return await orcaAddLiquidity( - network, - walletAddress, - positionAddress, - baseAmount, - quoteAmount, - slippagePct, - ); - - default: - throw httpErrors.badRequest(`Unsupported connector: ${connector}`); - } + const result = await (async () => { + switch (connector) { + case 'uniswap': + return await uniswapAddLiquidity( + network, + walletAddress, + positionAddress, + baseAmount, + quoteAmount, + slippagePct, + ); + + case 'pancakeswap': + return await pancakeswapAddLiquidity( + network, + walletAddress, + positionAddress, + baseAmount, + quoteAmount, + slippagePct, + ); + + case 'raydium': + return await raydiumAddLiquidity( + network, + walletAddress, + positionAddress, + baseAmount, + quoteAmount, + slippagePct, + ); + + case 'meteora': + return await meteoraAddLiquidity( + network, + walletAddress, + positionAddress, + baseAmount, + quoteAmount, + slippagePct, + strategyType, + ); + + case 'pancakeswap-sol': + return await pancakeswapSolAddLiquidity( + network, + walletAddress, + positionAddress, + baseAmount, + quoteAmount, + slippagePct, + ); + + case 'orca': + return await orcaAddLiquidity( + network, + walletAddress, + positionAddress, + baseAmount, + quoteAmount, + slippagePct, + ); + + default: + throw httpErrors.badRequest(`Unsupported connector: ${connector}`); + } + })(); + + // poolAddress comes from the connector, which already loaded the position. + return withIdentifiers(result, { positionAddress }); } catch (e: any) { - logger.error('Failed to add liquidity:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to add liquidity'); + rethrowRouteError(e, 'Failed to add liquidity'); } }, ); diff --git a/src/trading/trading-clmm-routes/close.ts b/src/trading/trading-clmm-routes/close.ts index 45b3469f73..ca97bd15b1 100644 --- a/src/trading/trading-clmm-routes/close.ts +++ b/src/trading/trading-clmm-routes/close.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { closePosition as meteoraClosePosition } from '../../connectors/meteora/clmm-routes/closePosition'; import { closePosition as orcaClosePosition } from '../../connectors/orca/clmm-routes/closePosition'; import { closePosition as pancakeswapClosePosition } from '../../connectors/pancakeswap/clmm-routes/closePosition'; @@ -11,57 +9,39 @@ import { closePosition as raydiumClosePosition } from '../../connectors/raydium/ import { closePosition as uniswapClosePosition } from '../../connectors/uniswap/clmm-routes/closePosition'; import { ClosePositionResponseType, ClosePositionResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + slippagePctField, + withIdentifiers, +} from '../common'; // Unified schema with connector field -const UnifiedClosePositionRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.String({ - description: 'Wallet address', - default: defaultWallet, - }), - positionAddress: Type.String({ - description: 'Position address', - examples: [''], - }), -}); +export const UnifiedClosePositionRequest = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address', + default: defaultWallet, + }), + positionAddress: Type.String({ + description: 'Position address', + examples: [''], + }), + slippagePct: slippagePctField( + 'Maximum acceptable slippage percentage for the withdrawal. Enforced by orca, uniswap ' + + 'and pancakeswap; meteora, raydium and pancakeswap-sol close with no minimum-amount ' + + "check at all, so it changes nothing there. Defaults to the connector's configured " + + 'slippagePct.', + ), + }, + { $id: 'ClmmCloseRequest', additionalProperties: false }, +); // Import connector functions @@ -83,40 +63,41 @@ export const closePositionRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { connector, chainNetwork, walletAddress, positionAddress } = request.body; + const { connector, chainNetwork, walletAddress, positionAddress, slippagePct } = request.body; // Parse chain and network from chainNetwork parameter - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'clmm'); // Route to appropriate connector - switch (connector) { - case 'uniswap': - return await uniswapClosePosition(network, walletAddress, positionAddress); + const result = await (async () => { + switch (connector) { + case 'uniswap': + return await uniswapClosePosition(network, walletAddress, positionAddress, slippagePct); + + case 'pancakeswap': + return await pancakeswapClosePosition(network, walletAddress, positionAddress, slippagePct); - case 'pancakeswap': - return await pancakeswapClosePosition(network, walletAddress, positionAddress); + case 'raydium': + return await raydiumClosePosition(network, walletAddress, positionAddress); - case 'raydium': - return await raydiumClosePosition(network, walletAddress, positionAddress); + case 'meteora': + return await meteoraClosePosition(network, walletAddress, positionAddress); - case 'meteora': - return await meteoraClosePosition(network, walletAddress, positionAddress); + case 'pancakeswap-sol': + return await pancakeswapSolClosePosition(network, walletAddress, positionAddress); - case 'pancakeswap-sol': - return await pancakeswapSolClosePosition(network, walletAddress, positionAddress); + case 'orca': + return await orcaClosePosition(network, walletAddress, positionAddress, slippagePct); - case 'orca': - return await orcaClosePosition(network, walletAddress, positionAddress); + default: + throw httpErrors.badRequest(`Unsupported connector: ${connector}`); + } + })(); - default: - throw httpErrors.badRequest(`Unsupported connector: ${connector}`); - } + // poolAddress comes from the connector, which already loaded the position. + return withIdentifiers(result, { positionAddress }); } catch (e: any) { - logger.error('Failed to close position:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to close position'); + rethrowRouteError(e, 'Failed to close position'); } }, ); diff --git a/src/trading/trading-clmm-routes/collect-fees.ts b/src/trading/trading-clmm-routes/collect-fees.ts index 8d0806e37f..9cc5175300 100644 --- a/src/trading/trading-clmm-routes/collect-fees.ts +++ b/src/trading/trading-clmm-routes/collect-fees.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { collectFees as meteoraCollectFees } from '../../connectors/meteora/clmm-routes/collectFees'; import { collectFees as orcaCollectFees } from '../../connectors/orca/clmm-routes/collectFees'; import { collectFees as pancakeswapCollectFees } from '../../connectors/pancakeswap/clmm-routes/collectFees'; @@ -11,57 +9,32 @@ import { collectFees as raydiumCollectFees } from '../../connectors/raydium/clmm import { collectFees as uniswapCollectFees } from '../../connectors/uniswap/clmm-routes/collectFees'; import { CollectFeesResponseType, CollectFeesResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + withIdentifiers, +} from '../common'; // Unified schema with connector field -const UnifiedCollectFeesRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.String({ - description: 'Wallet address', - default: defaultWallet, - }), - positionAddress: Type.String({ - description: 'Position address', - examples: [''], - }), -}); +export const UnifiedCollectFeesRequest = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address', + default: defaultWallet, + }), + positionAddress: Type.String({ + description: 'Position address', + examples: [''], + }), + }, + { $id: 'ClmmCollectFeesRequest', additionalProperties: false }, +); // Import connector functions @@ -86,37 +59,38 @@ export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { const { connector, chainNetwork, walletAddress, positionAddress } = request.body; // Parse chain and network from chainNetwork parameter - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'clmm'); // Route to appropriate connector - switch (connector) { - case 'uniswap': - return await uniswapCollectFees(network, walletAddress, positionAddress); + const result = await (async () => { + switch (connector) { + case 'uniswap': + return await uniswapCollectFees(network, walletAddress, positionAddress); + + case 'pancakeswap': + return await pancakeswapCollectFees(network, walletAddress, positionAddress); - case 'pancakeswap': - return await pancakeswapCollectFees(network, walletAddress, positionAddress); + case 'raydium': + return await raydiumCollectFees(network, walletAddress, positionAddress); - case 'raydium': - return await raydiumCollectFees(network, walletAddress, positionAddress); + case 'meteora': + return await meteoraCollectFees(network, walletAddress, positionAddress); - case 'meteora': - return await meteoraCollectFees(network, walletAddress, positionAddress); + case 'pancakeswap-sol': + return await pancakeswapSolCollectFees(network, walletAddress, positionAddress); - case 'pancakeswap-sol': - return await pancakeswapSolCollectFees(network, walletAddress, positionAddress); + case 'orca': + return await orcaCollectFees(network, walletAddress, positionAddress); - case 'orca': - return await orcaCollectFees(network, walletAddress, positionAddress); + default: + throw httpErrors.badRequest(`Unsupported connector: ${connector}`); + } + })(); - default: - throw httpErrors.badRequest(`Unsupported connector: ${connector}`); - } + // poolAddress comes from the connector, which already loaded the position. + return withIdentifiers(result, { positionAddress }); } catch (e: any) { - logger.error('Failed to collect fees:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to collect fees'); + rethrowRouteError(e, 'Failed to collect fees'); } }, ); diff --git a/src/trading/trading-clmm-routes/create-pool.ts b/src/trading/trading-clmm-routes/create-pool.ts index 9aef970949..1883dcf60c 100644 --- a/src/trading/trading-clmm-routes/create-pool.ts +++ b/src/trading/trading-clmm-routes/create-pool.ts @@ -1,74 +1,44 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { createPool as meteoraCreatePool } from '../../connectors/meteora/clmm-routes/createPool'; import { createPool as orcaCreatePool } from '../../connectors/orca/clmm-routes/createPool'; import { createPool as pancakeswapCreatePool } from '../../connectors/pancakeswap/clmm-routes/createPool'; import { createPool as pancakeswapSolCreatePool } from '../../connectors/pancakeswap-sol/clmm-routes/createPool'; import { createPool as raydiumCreatePool } from '../../connectors/raydium/clmm-routes/createPool'; import { createPool as uniswapCreatePool } from '../../connectors/uniswap/clmm-routes/createPool'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; +import { + CreatePoolResponse, + CreatePoolResponseType, + CreatePoolRequest as ClmmCreatePoolRequest, +} from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - defaultWallet = getSolanaChainConfig().defaultWallet; -} catch { - defaultWallet = getEthereumChainConfig().defaultWallet; -} - -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - return { chain: parts[0], network: parts.slice(1).join('-') }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, +} from '../common'; // Unified CLMM create-pool. Creates + initializes a pool at an initial price (no position is // seeded — concentrated-liquidity positions need a range, opened separately via open-position). // Per-connector extras are optional and consumed only by their owning connector. -const UnifiedClmmCreatePoolRequest = Type.Object({ - connector: Type.String({ - description: 'CLMM connector name (meteora, raydium, uniswap, orca, pancakeswap, pancakeswap-sol)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.String({ description: 'Wallet address (pool creator + payer)', default: defaultWallet }), - baseToken: Type.String({ description: 'Base token symbol or address' }), - quoteToken: Type.String({ description: 'Quote token symbol or address' }), - initialPrice: Type.Optional( - Type.Number({ - description: - 'Initial pool price as quote per base. If omitted, the current market price is fetched from the ' + - 'unified swap router so the pool opens on-market.', - }), - ), - // Connector-specific extras (optional; ignored by connectors that do not use them): - binStep: Type.Optional(Type.Number({ description: 'Meteora DLMM bin step (bps)' })), - feeBps: Type.Optional(Type.Number({ description: 'Meteora DLMM base fee (bps)' })), - ammConfigIndex: Type.Optional(Type.Number({ description: 'Raydium CLMM AMM config index (fee tier)' })), - fee: Type.Optional( - Type.Number({ - description: 'V3 fee tier — Uniswap (100 | 500 | 3000 | 10000) or PancakeSwap (100 | 500 | 2500 | 10000)', +// Composed from the canonical ClmmCreatePoolRequest (schemas/clmm-schema.ts): +// the unified route swaps per-connector `network` for connector + chainNetwork +// and defaults the wallet. +export const UnifiedClmmCreatePoolRequest = Type.Composite( + [ + Type.Object({ + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ description: 'Wallet address (pool creator + payer)', default: defaultWallet }), }), - ), - tickSpacing: Type.Optional(Type.Number({ description: 'Orca Whirlpool tick spacing (fee tier)' })), - ammConfig: Type.Optional(Type.String({ description: 'pancakeswap-sol CLMM amm_config account address (required)' })), - gasPrice: Type.Optional(Type.Number({ description: 'EVM gas price in gwei (uniswap/pancakeswap)' })), - maxGas: Type.Optional(Type.Number({ description: 'EVM max gas limit (uniswap/pancakeswap)' })), -}); + Type.Omit(ClmmCreatePoolRequest, ['network', 'walletAddress'], {}), + ], + { $id: 'ClmmCreatePoolRequest', additionalProperties: false }, +); export const createPoolRoute: FastifyPluginAsync = async (fastify) => { fastify.post<{ @@ -79,7 +49,8 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { { schema: { description: - 'Create and initialize a new CLMM pool across supported connectors (Meteora DLMM, Raydium CLMM, Uniswap V3)', + 'Create and initialize a new CLMM pool across supported connectors (Meteora DLMM, Raydium CLMM, ' + + 'PancakeSwap Solana CLMM, Orca Whirlpool, Uniswap V3, PancakeSwap V3)', tags: ['/trading/clmm'], body: UnifiedClmmCreatePoolRequest, response: { 200: CreatePoolResponse }, @@ -97,14 +68,19 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { binStep, feeBps, ammConfigIndex, - fee, - tickSpacing, - ammConfig, - gasPrice, - maxGas, } = request.body; - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'clmm'); + + // EVM V3 fee tiers are denominated in hundredths of a bip; feeBps is the + // route's one fee vocabulary, so map it (1 bps -> 100). + if ((connector === 'uniswap' || connector === 'pancakeswap') && feeBps === undefined) { + throw httpErrors.badRequest( + `feeBps is required for ${connector}: the V3 fee tier in basis points ` + + '(1, 5, 30 or 100; pancakeswap also 25)', + ); + } + const evmFeeTier = feeBps === undefined ? undefined : feeBps * 100; switch (connector) { case 'meteora': @@ -120,29 +96,13 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { case 'raydium': return await raydiumCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, ammConfigIndex); case 'uniswap': - return await uniswapCreatePool( - network, - walletAddress, - baseToken, - quoteToken, - initialPrice, - fee, - gasPrice, - maxGas, - ); + return await uniswapCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, evmFeeTier); case 'orca': - return await orcaCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, tickSpacing); + // Orca's fee tier IS its tick spacing — binStep is the route's one + // granularity vocabulary. + return await orcaCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, binStep); case 'pancakeswap': - return await pancakeswapCreatePool( - network, - walletAddress, - baseToken, - quoteToken, - initialPrice, - fee, - gasPrice, - maxGas, - ); + return await pancakeswapCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, evmFeeTier); case 'pancakeswap-sol': return await pancakeswapSolCreatePool( network, @@ -150,15 +110,13 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseToken, quoteToken, initialPrice, - ammConfig, + ammConfigIndex, ); default: throw httpErrors.badRequest(`Unsupported CLMM connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to create CLMM pool:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to create pool'); + rethrowRouteError(e, 'Failed to create CLMM pool'); } }, ); diff --git a/src/trading/trading-clmm-routes/fetchPools.ts b/src/trading/trading-clmm-routes/fetchPools.ts new file mode 100644 index 0000000000..0b6ab8f115 --- /dev/null +++ b/src/trading/trading-clmm-routes/fetchPools.ts @@ -0,0 +1,95 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { FetchPoolsResponse } from '../../schemas/clmm-schema'; +import { logger } from '../../services/logger'; +import { chainNetworkField, connectorField, parseChainNetwork, rethrowRouteError } from '../common'; +import { FETCH_POOLS_CONNECTORS, getFetchPoolsOps } from '../connector-registry'; + +/** + * Pool discovery against a DEX's own listing API, unified across connectors. + * + * The response shape was already shared; only the query knobs differed, so they + * are collected here as optional fields tagged with the connectors that honor + * them. A knob the chosen connector ignores is dropped rather than erroring, + * matching how the other unified routes treat connector-specific parameters. + */ +export const FetchPoolsRequestSchema = Type.Object( + { + chainNetwork: chainNetworkField(), + connector: connectorField(FETCH_POOLS_CONNECTORS, 'CLMM connector whose pool-discovery API to query'), + limit: Type.Optional( + Type.Number({ minimum: 1, maximum: 1000, default: 50, description: 'Maximum number of pools to return' }), + ), + query: Type.Optional( + Type.String({ description: 'Search pools by name, token, or address', examples: ['SOL', 'SOL-USDC'] }), + ), + sortBy: Type.Optional( + Type.String({ + description: + 'Sort field. Meteora takes a "field:direction" pair; Orca takes the field alone with sortDirection.', + examples: ['tvl', 'tvl:desc'], + }), + ), + page: Type.Optional( + Type.Number({ + minimum: 0, + description: '0-based page index. Only connectors whose API paginates honor this.', + 'x-connectors': ['meteora'], + } as any), + ), + includeUnverified: Type.Optional( + Type.Boolean({ description: 'Include unverified pools', 'x-connectors': ['meteora'] } as any), + ), + sortDirection: Type.Optional( + Type.String({ description: 'Sort direction', enum: ['asc', 'desc'], 'x-connectors': ['orca'] } as any), + ), + verifiedOnly: Type.Optional( + Type.Boolean({ description: 'Return only verified pools', 'x-connectors': ['orca'] } as any), + ), + }, + { $id: 'ClmmFetchPoolsRequest', additionalProperties: false }, +); + +type FetchPoolsRequest = Static; + +export const fetchPoolsRoute: FastifyPluginAsync = async (fastify) => { + fastify.get( + '/fetch-pools', + { + schema: { + description: "Discover pools from a CLMM connector's own pool-listing API", + tags: ['/trading/clmm'], + querystring: FetchPoolsRequestSchema, + response: { 200: FetchPoolsResponse }, + }, + }, + async (request, reply) => { + const { chainNetwork, connector, limit, query, sortBy, page, includeUnverified, sortDirection, verifiedOnly } = + request.query as FetchPoolsRequest; + + try { + const { chain, network } = parseChainNetwork(chainNetwork); + + logger.info(`[trading/clmm] fetch-pools on ${chain}/${network} via ${connector}`); + + const fetchPools = getFetchPoolsOps(connector, chain); + const result = await fetchPools({ + network, + limit, + query, + sortBy, + page, + includeUnverified, + sortDirection, + verifiedOnly, + }); + return reply.code(200).send(result); + } catch (e: any) { + rethrowRouteError(e, 'Failed to fetch pools'); + } + }, + ); +}; + +export default fetchPoolsRoute; diff --git a/src/trading/trading-clmm-routes/index.ts b/src/trading/trading-clmm-routes/index.ts index f557f7daf3..7c6dec37d6 100644 --- a/src/trading/trading-clmm-routes/index.ts +++ b/src/trading/trading-clmm-routes/index.ts @@ -4,3 +4,16 @@ export { removeLiquidityRoute } from './remove'; export { collectFeesRoute } from './collect-fees'; export { closePositionRoute } from './close'; export { createPoolRoute } from './create-pool'; +export { fetchPoolsRoute } from './fetchPools'; + +// The request schemas, re-exported so app.ts can publish them as spec components. +// Registering a schema and referencing it are independent: `addSchema` puts it in +// components.schemas, while @fastify/swagger still expands a GET's querystring into +// `parameters`. So the GETs are published here too, and the operations are unchanged. +export { UnifiedOpenPositionRequest } from './open'; +export { UnifiedAddLiquidityRequest } from './add'; +export { UnifiedRemoveLiquidityRequest } from './remove'; +export { UnifiedCollectFeesRequest } from './collect-fees'; +export { UnifiedClosePositionRequest } from './close'; +export { UnifiedClmmCreatePoolRequest } from './create-pool'; +export { FetchPoolsRequestSchema } from './fetchPools'; diff --git a/src/trading/trading-clmm-routes/open.ts b/src/trading/trading-clmm-routes/open.ts index a2739b0524..2bf17b5122 100644 --- a/src/trading/trading-clmm-routes/open.ts +++ b/src/trading/trading-clmm-routes/open.ts @@ -1,21 +1,24 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; +import { openPosition as meteoraOpenPosition } from '../../connectors/meteora/clmm-routes/openPosition'; +import { openPosition as orcaOpenPosition } from '../../connectors/orca/clmm-routes/openPosition'; +import { openPosition as pancakeswapOpenPosition } from '../../connectors/pancakeswap/clmm-routes/openPosition'; +import { openPosition as pancakeswapSolOpenPosition } from '../../connectors/pancakeswap-sol/clmm-routes/openPosition'; +import { openPosition as raydiumOpenPosition } from '../../connectors/raydium/clmm-routes/openPosition'; +import { openPosition as uniswapOpenPosition } from '../../connectors/uniswap/clmm-routes/openPosition'; +import { OpenPositionResponseType, OpenPositionResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { Ethereum } from '../../chains/ethereum/ethereum'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { Solana } from '../../chains/solana/solana'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + withIdentifiers, + slippagePctField, +} from '../common'; // Constants for examples (using Meteora CLMM values) const BASE_TOKEN_AMOUNT = 0.01; @@ -24,91 +27,55 @@ const LOWER_PRICE_BOUND = 150; const UPPER_PRICE_BOUND = 250; const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - // Unified schema with connector field -const UnifiedOpenPositionRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.String({ - description: 'Wallet address', - default: defaultWallet, - }), - lowerPrice: Type.Number({ - description: 'Lower price bound for the position', - examples: [LOWER_PRICE_BOUND], - }), - upperPrice: Type.Number({ - description: 'Upper price bound for the position', - examples: [UPPER_PRICE_BOUND], - }), - poolAddress: Type.String({ - description: 'Pool address', - examples: [CLMM_POOL_ADDRESS_EXAMPLE], - }), - baseTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of base token to deposit', - examples: [BASE_TOKEN_AMOUNT], +export const UnifiedOpenPositionRequest = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address', + default: defaultWallet, }), - ), - quoteTokenAmount: Type.Optional( - Type.Number({ - description: 'Amount of quote token to deposit', - examples: [QUOTE_TOKEN_AMOUNT], + lowerPrice: Type.Number({ + format: 'decimal', + description: 'Lower price bound for the position', + examples: [LOWER_PRICE_BOUND], }), - ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: 1, - examples: [1], + upperPrice: Type.Number({ + format: 'decimal', + description: 'Upper price bound for the position', + examples: [UPPER_PRICE_BOUND], }), - ), - // Meteora-specific parameter (optional, ignored by other connectors) - strategyType: Type.Optional( - Type.Number({ - description: 'Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.', - examples: [0], + poolAddress: Type.String({ + description: 'Pool address', + examples: [CLMM_POOL_ADDRESS_EXAMPLE], }), - ), -}); - -// Import connector functions -import { openPosition as meteoraOpenPosition } from '../../connectors/meteora/clmm-routes/openPosition'; -import { openPosition as orcaOpenPosition } from '../../connectors/orca/clmm-routes/openPosition'; -import { openPosition as pancakeswapOpenPosition } from '../../connectors/pancakeswap/clmm-routes/openPosition'; -import { openPosition as pancakeswapSolOpenPosition } from '../../connectors/pancakeswap-sol/clmm-routes/openPosition'; -import { openPosition as raydiumOpenPosition } from '../../connectors/raydium/clmm-routes/openPosition'; -import { openPosition as uniswapOpenPosition } from '../../connectors/uniswap/clmm-routes/openPosition'; -import { OpenPositionResponseType, OpenPositionResponse } from '../../schemas/clmm-schema'; -import { logger } from '../../services/logger'; + baseTokenAmount: Type.Optional( + Type.Number({ + format: 'decimal', + description: 'Amount of base token to deposit', + examples: [BASE_TOKEN_AMOUNT], + }), + ), + quoteTokenAmount: Type.Optional( + Type.Number({ + format: 'decimal', + description: 'Amount of quote token to deposit', + examples: [QUOTE_TOKEN_AMOUNT], + }), + ), + slippagePct: slippagePctField(), + // Meteora-specific parameter (optional, ignored by other connectors) + strategyType: Type.Optional( + Type.Number({ + 'x-connectors': ['meteora'], + description: 'Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.', + examples: [0], + }), + ), + }, + { $id: 'ClmmOpenRequest', additionalProperties: false }, +); export const openPositionRoute: FastifyPluginAsync = async (fastify) => { fastify.post<{ @@ -142,92 +109,100 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { } = request.body; // Parse chain and network from chainNetwork parameter - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'clmm'); + + // Same contract as add.ts: single-sided opens are valid, but at least one + // side must be positive — reject here rather than deep in connector code. + const baseAmount = baseTokenAmount ?? 0; + const quoteAmount = quoteTokenAmount ?? 0; + if (baseAmount <= 0 && quoteAmount <= 0) { + throw httpErrors.badRequest('At least one of baseTokenAmount or quoteTokenAmount must be greater than 0'); + } // Route to appropriate connector - switch (connector) { - case 'uniswap': - return await uniswapOpenPosition( - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - - case 'pancakeswap': - return await pancakeswapOpenPosition( - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - - case 'raydium': - return await raydiumOpenPosition( - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - - case 'meteora': - return await meteoraOpenPosition( - network, - walletAddress, - lowerPrice, - upperPrice, - poolAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - strategyType, - ); - - case 'pancakeswap-sol': - return await pancakeswapSolOpenPosition( - network, - walletAddress, - poolAddress, - lowerPrice, - upperPrice, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - - case 'orca': - return await orcaOpenPosition( - network, - walletAddress, - poolAddress, - lowerPrice, - upperPrice, - baseTokenAmount, - quoteTokenAmount, - slippagePct, - ); - - default: - throw httpErrors.badRequest(`Unsupported connector: ${connector}`); - } + const result = await (async () => { + switch (connector) { + case 'uniswap': + return await uniswapOpenPosition( + network, + walletAddress, + lowerPrice, + upperPrice, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + + case 'pancakeswap': + return await pancakeswapOpenPosition( + network, + walletAddress, + lowerPrice, + upperPrice, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + + case 'raydium': + return await raydiumOpenPosition( + network, + walletAddress, + lowerPrice, + upperPrice, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + + case 'meteora': + return await meteoraOpenPosition( + network, + walletAddress, + lowerPrice, + upperPrice, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + strategyType, + ); + + case 'pancakeswap-sol': + return await pancakeswapSolOpenPosition( + network, + walletAddress, + poolAddress, + lowerPrice, + upperPrice, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + + case 'orca': + return await orcaOpenPosition( + network, + walletAddress, + poolAddress, + lowerPrice, + upperPrice, + baseTokenAmount, + quoteTokenAmount, + slippagePct, + ); + + default: + throw httpErrors.badRequest(`Unsupported connector: ${connector}`); + } + })(); + + return withIdentifiers(result, { poolAddress }); } catch (e: any) { - logger.error('Failed to open position:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to open position'); + rethrowRouteError(e, 'Failed to open position'); } }, ); diff --git a/src/trading/trading-clmm-routes/remove.ts b/src/trading/trading-clmm-routes/remove.ts index f76b555603..d2bd54926e 100644 --- a/src/trading/trading-clmm-routes/remove.ts +++ b/src/trading/trading-clmm-routes/remove.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { removeLiquidity as meteoraRemoveLiquidity } from '../../connectors/meteora/clmm-routes/removeLiquidity'; import { removeLiquidity as orcaRemoveLiquidity } from '../../connectors/orca/clmm-routes/removeLiquidity'; import { removeLiquidity as pancakeswapRemoveLiquidity } from '../../connectors/pancakeswap/clmm-routes/removeLiquidity'; @@ -11,64 +9,47 @@ import { removeLiquidity as raydiumRemoveLiquidity } from '../../connectors/rayd import { removeLiquidity as uniswapRemoveLiquidity } from '../../connectors/uniswap/clmm-routes/removeLiquidity'; import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + resolveChainNetwork, + rethrowRouteError, + withIdentifiers, + slippagePctField, +} from '../common'; // Unified schema with connector field -const UnifiedRemoveLiquidityRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), - walletAddress: Type.String({ - description: 'Wallet address', - default: defaultWallet, - }), - positionAddress: Type.String({ - description: 'Position address', - examples: [''], - }), - percentageToRemove: Type.Number({ - minimum: 0, - maximum: 100, - description: 'Percentage of liquidity to remove', - default: 100, - examples: [100], - }), -}); +export const UnifiedRemoveLiquidityRequest = Type.Object( + { + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector', { defaulted: false }), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address', + default: defaultWallet, + }), + positionAddress: Type.String({ + description: 'Position address', + examples: [''], + }), + percentageToRemove: Type.Number({ + format: 'decimal', + minimum: 0, + maximum: 100, + description: 'Percentage of liquidity to remove', + default: 100, + examples: [100], + }), + // Orca-specific parameter (optional, ignored by other connectors, which manage + // slippage internally). + slippagePct: slippagePctField( + 'Maximum acceptable slippage percentage. Honored by orca, uniswap and pancakeswap; ' + + "the other connectors remove at their configured slippagePct. Defaults to the connector's configured slippagePct.", + ), + }, + { $id: 'ClmmRemoveRequest', additionalProperties: false }, +); // Import connector functions @@ -90,40 +71,60 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { connector, chainNetwork, walletAddress, positionAddress, percentageToRemove } = request.body; + const { connector, chainNetwork, walletAddress, positionAddress, percentageToRemove, slippagePct } = + request.body; // Parse chain and network from chainNetwork parameter - const { network } = parseChainNetwork(chainNetwork); + const { network } = resolveChainNetwork(chainNetwork, connector, 'clmm'); // Route to appropriate connector - switch (connector) { - case 'uniswap': - return await uniswapRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); - - case 'pancakeswap': - return await pancakeswapRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); - - case 'raydium': - return await raydiumRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); - - case 'meteora': - return await meteoraRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); - - case 'pancakeswap-sol': - return await pancakeswapSolRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); - - case 'orca': - return await orcaRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove, 1); - - default: - throw httpErrors.badRequest(`Unsupported connector: ${connector}`); - } + const result = await (async () => { + switch (connector) { + case 'uniswap': + return await uniswapRemoveLiquidity( + network, + walletAddress, + positionAddress, + percentageToRemove, + slippagePct, + ); + + case 'pancakeswap': + return await pancakeswapRemoveLiquidity( + network, + walletAddress, + positionAddress, + percentageToRemove, + slippagePct, + ); + + case 'raydium': + return await raydiumRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); + + case 'meteora': + return await meteoraRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); + + case 'pancakeswap-sol': + return await pancakeswapSolRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); + + case 'orca': + return await orcaRemoveLiquidity( + network, + walletAddress, + positionAddress, + percentageToRemove, + slippagePct, + ); + + default: + throw httpErrors.badRequest(`Unsupported connector: ${connector}`); + } + })(); + + // poolAddress comes from the connector, which already loaded the position. + return withIdentifiers(result, { positionAddress }); } catch (e: any) { - logger.error('Failed to remove liquidity:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to remove liquidity'); + rethrowRouteError(e, 'Failed to remove liquidity'); } }, ); diff --git a/src/trading/trading-router-routes/executeQuote.ts b/src/trading/trading-router-routes/executeQuote.ts new file mode 100644 index 0000000000..5a1712cb0b --- /dev/null +++ b/src/trading/trading-router-routes/executeQuote.ts @@ -0,0 +1,70 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { ChainExecuteSwapResponseSchema } from '../../schemas/chain-schema'; +import { logger } from '../../services/logger'; +import { + chainNetworkField, + connectorField, + parseChainNetwork, + rethrowRouteError, + resolveSwapConnector, + walletAddressField, +} from '../common'; +import { ROUTER_CONNECTORS, getRouterOps } from '../connector-registry'; + +/** + * Executing a cached quote is router-only: a quote id refers to route calldata the + * router built and Gateway cached, which pool-scoped amm/clmm swaps have no + * equivalent of — they price against a pool at execution time. + */ +export const RouterExecuteQuoteRequestSchema = Type.Object( + { + chainNetwork: chainNetworkField(), + connector: Type.Optional( + // No schema default: AJV injects defaults before the handler, so one here would + // hand resolveSwapConnector the first router in the registry and the configured + // swapProvider would never be consulted — including on Ethereum, where the first + // router is a Solana connector. + connectorField(ROUTER_CONNECTORS, "Router connector. Defaults to the network's swapProvider", { + defaulted: false, + }), + ), + walletAddress: walletAddressField('Wallet address that will execute the quote'), + quoteId: Type.String({ description: 'ID of a quote returned by /trading/router/quote-swap' }), + }, + { $id: 'RouterExecuteQuoteRequest', additionalProperties: false }, +); + +type RouterExecuteQuoteRequest = Static; + +export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { + fastify.post( + '/execute-quote', + { + schema: { + description: 'Execute a previously fetched router quote by its quote id', + tags: ['/trading/router'], + body: RouterExecuteQuoteRequestSchema, + response: { 200: ChainExecuteSwapResponseSchema }, + }, + }, + async (request, reply) => { + const { chainNetwork, connector, walletAddress, quoteId } = request.body as RouterExecuteQuoteRequest; + + try { + const { chain, network } = parseChainNetwork(chainNetwork); + const name = resolveSwapConnector(chain, network, 'router', connector); + + logger.info(`[trading/router] execute quote ${quoteId} on ${chain}/${network} via ${name}`); + + const result = await getRouterOps(name, chain).executeQuote(walletAddress, network, quoteId); + return reply.code(200).send(result); + } catch (e: any) { + rethrowRouteError(e, 'Failed to execute quote'); + } + }, + ); +}; + +export default executeQuoteRoute; diff --git a/src/trading/trading-router-routes/executeSwap.ts b/src/trading/trading-router-routes/executeSwap.ts new file mode 100644 index 0000000000..1011ccae32 --- /dev/null +++ b/src/trading/trading-router-routes/executeSwap.ts @@ -0,0 +1,116 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { ChainExecuteSwapResponseSchema } from '../../schemas/chain-schema'; +import { logger } from '../../services/logger'; +import { ensureTokenSaved, recordQuietly } from '../../services/token-pool-autosave'; +import { + chainNetworkField, + connectorField, + parseChainNetwork, + rethrowRouteError, + resolveSwapConnector, + slippagePctField, + walletAddressField, +} from '../common'; +import { APPROXIMATE_IF_NO_EXACT_OUT_CONNECTORS, ROUTER_CONNECTORS, getRouterOps } from '../connector-registry'; + +export const RouterExecuteSwapRequestSchema = Type.Object( + { + chainNetwork: chainNetworkField(), + connector: Type.Optional( + // No schema default: AJV injects defaults before the handler, so one here would + // hand resolveSwapConnector the first router in the registry and the configured + // swapProvider would never be consulted — including on Ethereum, where the first + // router is a Solana connector. + connectorField(ROUTER_CONNECTORS, "Router connector. Defaults to the network's swapProvider", { + defaulted: false, + }), + ), + walletAddress: walletAddressField('Wallet address that will execute the swap'), + baseToken: Type.String({ description: 'Symbol or address of the base token', default: 'SOL' }), + quoteToken: Type.String({ description: 'Symbol or address of the quote token', default: 'USDC' }), + amount: Type.Number({ + format: 'decimal', + description: 'Amount of base token to trade', + default: 0.01, + }), + side: Type.String({ + description: 'BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + slippagePct: slippagePctField(), + approximateIfNoExactOut: Type.Optional( + Type.Boolean({ + description: + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.', + default: true, + 'x-connectors': APPROXIMATE_IF_NO_EXACT_OUT_CONNECTORS, + } as any), + ), + }, + { $id: 'RouterExecuteSwapRequest', additionalProperties: false }, +); + +type RouterExecuteSwapRequest = Static; + +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post( + '/execute-swap', + { + schema: { + description: 'Quote and execute a swap through a router connector on any supported chain', + tags: ['/trading/router'], + body: RouterExecuteSwapRequestSchema, + response: { 200: ChainExecuteSwapResponseSchema }, + }, + }, + async (request, reply) => { + const { + chainNetwork, + connector, + walletAddress, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + } = request.body as RouterExecuteSwapRequest; + + try { + const { chain, network } = parseChainNetwork(chainNetwork); + const name = resolveSwapConnector(chain, network, 'router', connector); + + logger.info( + `[trading/router] execute ${side} ${amount} ${baseToken}-${quoteToken} on ${chain}/${network} via ${name}`, + ); + + const result = await getRouterOps(name, chain).executeSwap({ + network, + walletAddress, + baseToken, + quoteToken, + amount, + side: side as 'BUY' | 'SELL', + slippagePct, + approximateIfNoExactOut, + }); + // A router swap names its tokens directly, so an address Gateway has never seen + // is the only chance it gets to learn one. Symbols resolve from the list and cost + // nothing here; an unknown address is read from the chain once and then known. + await recordQuietly( + Promise.all([ensureTokenSaved(chain, network, baseToken), ensureTokenSaved(chain, network, quoteToken)]), + `tokens ${baseToken} and ${quoteToken}`, + ); + + return reply.code(200).send(result); + } catch (e: any) { + rethrowRouteError(e, 'Failed to execute swap'); + } + }, + ); +}; + +export default executeSwapRoute; diff --git a/src/trading/trading-router-routes/index.ts b/src/trading/trading-router-routes/index.ts new file mode 100644 index 0000000000..cfb2a476b9 --- /dev/null +++ b/src/trading/trading-router-routes/index.ts @@ -0,0 +1,9 @@ +export { quoteSwapRoute } from './quoteSwap'; +export { executeQuoteRoute } from './executeQuote'; +export { executeSwapRoute } from './executeSwap'; + +// The request bodies, re-exported so app.ts can publish them as spec components. +// quote-swap is a GET, so its fields are `parameters` and it has no component. +export { RouterExecuteQuoteRequestSchema } from './executeQuote'; +export { RouterExecuteSwapRequestSchema } from './executeSwap'; +export { RouterQuoteSwapRequestSchema } from './quoteSwap'; diff --git a/src/trading/trading-router-routes/quoteSwap.ts b/src/trading/trading-router-routes/quoteSwap.ts new file mode 100644 index 0000000000..c6af2a9e76 --- /dev/null +++ b/src/trading/trading-router-routes/quoteSwap.ts @@ -0,0 +1,134 @@ +import { Type, Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; + +import { RouterQuoteSwapResponseSchema } from '../../schemas/chain-schema'; +import { logger } from '../../services/logger'; +import { ensureTokenSaved, recordQuietly } from '../../services/token-pool-autosave'; +import { + chainNetworkField, + connectorField, + parseChainNetwork, + rethrowRouteError, + resolveSwapConnector, + slippagePctField, +} from '../common'; +import { + APPROXIMATE_IF_NO_EXACT_OUT_CONNECTORS, + INDICATIVE_PRICE_CONNECTORS, + ROUTER_CONNECTORS, + getRouterOps, +} from '../connector-registry'; + +export const RouterQuoteSwapRequestSchema = Type.Object( + { + chainNetwork: chainNetworkField(), + connector: Type.Optional( + // No schema default: AJV injects defaults before the handler, so one here would + // hand resolveSwapConnector the first router in the registry and the configured + // swapProvider would never be consulted — including on Ethereum, where the first + // router is a Solana connector. + connectorField(ROUTER_CONNECTORS, "Router connector. Defaults to the network's swapProvider", { + defaulted: false, + }), + ), + baseToken: Type.String({ description: 'Symbol or address of the base token', default: 'SOL' }), + quoteToken: Type.String({ description: 'Symbol or address of the quote token', default: 'USDC' }), + amount: Type.Number({ + format: 'decimal', + description: 'Amount of base token to trade', + default: 1, + }), + side: Type.String({ + description: 'BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + default: 'SELL', + }), + slippagePct: slippagePctField(), + walletAddress: Type.Optional( + Type.String({ + description: + 'Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata.', + }), + ), + approximateIfNoExactOut: Type.Optional( + Type.Boolean({ + description: + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing.', + default: true, + 'x-connectors': APPROXIMATE_IF_NO_EXACT_OUT_CONNECTORS, + } as any), + ), + // Deliberately no schema default: Fastify injects defaults before the handler + // runs, so one here would shadow the connector's own. + indicativePrice: Type.Optional( + Type.Boolean({ + description: + 'Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote.', + 'x-connectors': INDICATIVE_PRICE_CONNECTORS, + } as any), + ), + }, + { $id: 'RouterQuoteSwapRequest', additionalProperties: false }, +); + +type RouterQuoteSwapRequest = Static; + +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get( + '/quote-swap', + { + schema: { + description: 'Get a swap quote from a router connector on any supported chain', + tags: ['/trading/router'], + querystring: RouterQuoteSwapRequestSchema, + response: { 200: RouterQuoteSwapResponseSchema }, + }, + }, + async (request, reply) => { + const { + chainNetwork, + connector, + baseToken, + quoteToken, + amount, + side, + slippagePct, + walletAddress, + approximateIfNoExactOut, + indicativePrice, + } = request.query as RouterQuoteSwapRequest; + + try { + const { chain, network } = parseChainNetwork(chainNetwork); + const name = resolveSwapConnector(chain, network, 'router', connector); + + logger.info(`[trading/router] quote ${baseToken}-${quoteToken} on ${chain}/${network} via ${name}`); + + const result = await getRouterOps(name, chain).quoteSwap({ + network, + baseToken, + quoteToken, + amount, + side: side as 'BUY' | 'SELL', + slippagePct, + approximateIfNoExactOut, + indicativePrice, + walletAddress, + }); + // A router swap names its tokens directly, so an address Gateway has never seen + // is the only chance it gets to learn one. Symbols resolve from the list and cost + // nothing here; an unknown address is read from the chain once and then known. + await recordQuietly( + Promise.all([ensureTokenSaved(chain, network, baseToken), ensureTokenSaved(chain, network, quoteToken)]), + `tokens ${baseToken} and ${quoteToken}`, + ); + + return reply.code(200).send(result); + } catch (e: any) { + rethrowRouteError(e, 'Failed to get swap quote'); + } + }, + ); +}; + +export default quoteSwapRoute; diff --git a/src/trading/trading.routes.ts b/src/trading/trading.routes.ts index b892c6a5f3..d8102f1745 100644 --- a/src/trading/trading.routes.ts +++ b/src/trading/trading.routes.ts @@ -4,16 +4,13 @@ import { FastifyPluginAsync } from 'fastify'; import { poolsRoute } from './clmm/pools'; import { positionsRoute } from './clmm/positions'; import { positionsOwnedRoute } from './clmm/positions-owned'; -import { quotePositionRoute } from './clmm/quote-position'; -import { executeSwapRoute } from './swap/execute'; -import { quoteSwapRoute } from './swap/quote'; +import { quoteLiquidityRoute } from './clmm/quote-liquidity'; +import { makeExecuteSwapRoute, makeQuoteSwapRoute } from './pool-swap-routes'; import { createPoolRoute, poolInfoRoute as ammPoolInfoRoute, positionInfoRoute as ammPositionInfoRoute, positionsOwnedRoute as ammPositionsOwnedRoute, - quoteSwapRoute as ammQuoteSwapRoute, - executeSwapRoute as ammExecuteSwapRoute, quoteLiquidityRoute as ammQuoteLiquidityRoute, addLiquidityRoute as ammAddLiquidityRoute, removeLiquidityRoute as ammRemoveLiquidityRoute, @@ -25,26 +22,38 @@ import { collectFeesRoute, closePositionRoute, createPoolRoute as clmmCreatePoolRoute, + fetchPoolsRoute, } from './trading-clmm-routes'; +import { quoteSwapRoute, executeQuoteRoute, executeSwapRoute } from './trading-router-routes'; -export const tradingSwapRoutes: FastifyPluginAsync = async (fastify) => { +/** + * Router connectors (Jupiter, 0x, ...): quote-swap / execute-quote / execute-swap. + * Mounted at /trading/router, exposing the same verb set the per-connector router + * routes used to. + */ +export const tradingRouterRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(sensible); - // Register swap routes fastify.register(quoteSwapRoute); + fastify.register(executeQuoteRoute); fastify.register(executeSwapRoute); }; export const tradingClmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(sensible); - // Register CLMM query routes + // Query routes fastify.register(poolsRoute); fastify.register(positionsRoute); fastify.register(positionsOwnedRoute); - fastify.register(quotePositionRoute); + fastify.register(quoteLiquidityRoute); + fastify.register(fetchPoolsRoute); - // Register CLMM transaction routes + // Swap routes (single-pool swaps; execute-quote is router-only) + fastify.register(makeQuoteSwapRoute('clmm')); + fastify.register(makeExecuteSwapRoute('clmm')); + + // Liquidity transaction routes fastify.register(openPositionRoute); fastify.register(addLiquidityRoute); fastify.register(removeLiquidityRoute); @@ -56,21 +65,18 @@ export const tradingClmmRoutes: FastifyPluginAsync = async (fastify) => { export const tradingAmmRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(sensible); - // Register AMM query routes (unified cross-connector) + // Query routes fastify.register(ammPoolInfoRoute); fastify.register(ammPositionInfoRoute); fastify.register(ammPositionsOwnedRoute); - fastify.register(ammQuoteSwapRoute); fastify.register(ammQuoteLiquidityRoute); - // Register AMM transaction routes (unified cross-connector) - fastify.register(ammExecuteSwapRoute); + // Swap routes (single-pool swaps; execute-quote is router-only) + fastify.register(makeQuoteSwapRoute('amm')); + fastify.register(makeExecuteSwapRoute('amm')); + + // Liquidity transaction routes fastify.register(ammAddLiquidityRoute); fastify.register(ammRemoveLiquidityRoute); fastify.register(createPoolRoute); }; - -// Legacy export for backward compatibility -export const tradingRoutes = tradingSwapRoutes; - -export default tradingRoutes; diff --git a/src/wallet/routes/addHardwareWallet.ts b/src/wallet/routes/addHardwareWallet.ts index 395079c101..cd6aaaa46c 100644 --- a/src/wallet/routes/addHardwareWallet.ts +++ b/src/wallet/routes/addHardwareWallet.ts @@ -12,7 +12,7 @@ import { AddHardwareWalletRequestSchema, AddHardwareWalletResponseSchema, } from '../schemas'; -import { validateChainName, getHardwareWallets, saveHardwareWallets, HardwareWalletData } from '../utils'; +import { validateChainName, getHardwareWallets, saveHardwareWallets } from '../utils'; // Maximum number of account indices to check when searching for an address const MAX_ACCOUNTS_TO_CHECK = 8; diff --git a/src/wallet/routes/removeWallet.ts b/src/wallet/routes/removeWallet.ts index c5888df55b..366dbe7744 100644 --- a/src/wallet/routes/removeWallet.ts +++ b/src/wallet/routes/removeWallet.ts @@ -1,5 +1,4 @@ import sensible from '@fastify/sensible'; -import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../chains/ethereum/ethereum'; diff --git a/src/wallet/schemas.ts b/src/wallet/schemas.ts index 944d07270c..63adf2f5f4 100644 --- a/src/wallet/schemas.ts +++ b/src/wallet/schemas.ts @@ -48,16 +48,19 @@ export const GetWalletResponseSchema = Type.Object({ ), }); -export const RemoveWalletRequestSchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain to remove wallet from', - enum: ['ethereum', 'solana'], - examples: ['solana', 'ethereum'], - }), - address: Type.String({ - description: 'Wallet address to remove', - }), -}); +export const RemoveWalletRequestSchema = Type.Object( + { + chain: Type.String({ + description: 'Blockchain to remove wallet from', + enum: ['ethereum', 'solana'], + examples: ['solana', 'ethereum'], + }), + address: Type.String({ + description: 'Wallet address to remove', + }), + }, + { $id: 'RemoveWalletRequest', additionalProperties: false }, +); export const RemoveWalletResponseSchema = Type.Object({ message: Type.String({ @@ -77,23 +80,26 @@ export const SignMessageResponseSchema = Type.Object({ }); // Hardware wallet schemas -export const AddHardwareWalletRequestSchema = Type.Object({ - chain: Type.String({ - description: 'Blockchain for hardware wallet', - enum: ['ethereum', 'solana'], - default: 'solana', - examples: ['solana', 'ethereum'], - }), - address: Type.String({ - description: 'Hardware wallet address to add (must exist on connected Ledger device)', - }), - setDefault: Type.Optional( - Type.Boolean({ - description: 'Set this wallet as the default for the chain', - default: false, +export const AddHardwareWalletRequestSchema = Type.Object( + { + chain: Type.String({ + description: 'Blockchain for hardware wallet', + enum: ['ethereum', 'solana'], + default: 'solana', + examples: ['solana', 'ethereum'], }), - ), -}); + address: Type.String({ + description: 'Hardware wallet address to add (must exist on connected Ledger device)', + }), + setDefault: Type.Optional( + Type.Boolean({ + description: 'Set this wallet as the default for the chain', + default: false, + }), + ), + }, + { $id: 'AddHardwareWalletRequest', additionalProperties: false }, +); export const AddHardwareWalletResponseSchema = Type.Object({ address: Type.String({ diff --git a/src/wallet/utils.ts b/src/wallet/utils.ts index 6f667a8913..495fde4097 100644 --- a/src/wallet/utils.ts +++ b/src/wallet/utils.ts @@ -28,7 +28,7 @@ export const walletPath = './conf/wallets'; // Utility to sanitize file paths and prevent path traversal attacks export function sanitizePathComponent(input: string): string { // Remove any characters that could be used for directory traversal - return input.replace(/[\/\\:*?"<>|]/g, ''); + return input.replace(/[/\\:*?"<>|]/g, ''); } // Import supported chains function @@ -93,7 +93,7 @@ export async function addWallet(fastify: FastifyInstance, req: AddWalletRequest) const network = req.chain === 'solana' ? 'mainnet-beta' : 'mainnet'; try { - connection = await getInitializedChain(req.chain, network); + connection = await getInitializedChain(req.chain, network); } catch (e) { if (e instanceof UnsupportedChainException) { throw fastify.httpErrors.badRequest(`Unrecognized chain name: ${req.chain}`); diff --git a/test/README.md b/test/README.md index 8ab9be28e6..e504f03878 100644 --- a/test/README.md +++ b/test/README.md @@ -6,60 +6,26 @@ This directory contains comprehensive test suites for the Gateway API. The test ``` /test - /chains/ # Chain endpoint tests - chain.test.js # Chain routes test - ethereum.test.js # Ethereum chain tests - solana.test.js # Solana chain tests - /connectors/ # Connector endpoint tests by protocol - /jupiter/ # Jupiter connector tests - /router-routes/ # Router operation tests - quoteSwap.test.ts - executeSwap.test.ts - executeQuote.test.ts - /uniswap/ # Uniswap connector tests - /router-routes/ # Universal Router tests - quoteSwap.test.ts - executeSwap.test.ts - executeQuote.test.ts - /amm-routes/ # V2 AMM tests - quote-swap.test.ts - add-liquidity.test.ts - /clmm-routes/ # V3 CLMM tests - quote-swap.test.ts - pool-info.test.ts - /raydium/ # Raydium connector tests - /amm-routes/ # AMM operation tests - /clmm-routes/ # CLMM operation tests - /meteora/ # Meteora connector tests - /clmm-routes/ # CLMM operation tests - /0x/ # 0x connector tests - /router-routes/ # Router operation tests - getPrice.test.ts - quoteSwap.test.ts - executeSwap.test.ts - executeQuote.test.ts - /mocks/ # Mock response data - /chains/ # Chain mock responses - chains.json # Chain list response - /ethereum/ # Ethereum mock responses - balance.json - status.json - tokens.json - /solana/ # Solana mock responses - balance.json - status.json - tokens.json - /connectors/ # Connector mock responses - connectors.json # Connector list response - /jupiter/ - /raydium/ - /meteora/ - /uniswap/ - /services/ # Service tests - /data/ # Test data files - /wallet/ # Wallet tests - /config/ # Configuration tests - jest-setup.js # Test environment configuration + app.integration.test.ts # Whole-app wiring + /chains/ # Chain route tests + chain.routes.test.ts # The parameterized /chains/{chain} table + /ethereum/ # Ethereum chain + its routes + /solana/ # Solana chain + its routes + /connectors/ # Connector tests, one directory per connector + /0x/ /dflow/ /jupiter/ /okx/ /titan/ # router-only connectors + /meteora/ /orca/ /raydium/ /pancakeswap-sol/ # Solana AMM/CLMM + /pancakeswap/ /uniswap/ # EVM AMM/CLMM + /router-routes/ /amm-routes/ /clmm-routes/ # by trading type + /trading/ # The unified /trading/* routes themselves + /clmm/ /pool-swap/ /trading-amm-routes/ /trading-clmm-routes/ + /mocks/ # Shared mock modules (TypeScript, not fixtures) + app-mocks.ts # Import before importing app + shared-mocks.ts + /0x/ /orca/ # Per-connector mock data modules + /helpers/ # commonMocks, connectorMocks, connector-test-utils + /utils/testUtils.ts # fastifyWithTypeProvider — use this to build a server + /config/ /pools/ /rpc/ /services/ /tokens/ /wallet/ + jest-setup.js # Test environment configuration ``` ## Running Tests @@ -79,10 +45,10 @@ GATEWAY_TEST_MODE=dev jest --runInBand test/chains # Run specific connector tests GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/uniswap -GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/raydium/amm.test.js +GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/raydium # Run a single test file -GATEWAY_TEST_MODE=dev jest --runInBand test/chains/ethereum.test.js +GATEWAY_TEST_MODE=dev jest --runInBand test/chains/ethereum/routes/status.test.ts # Clear Jest cache if tests are behaving unexpectedly pnpm test:clear-cache @@ -120,98 +86,91 @@ The test environment is configured in `test/jest-setup.js`, which: **Note**: Always use `GATEWAY_TEST_MODE=dev` for unit tests to avoid real blockchain connections -## Mock Responses +## Mocking -Tests use mock responses stored in JSON files in the `test/mocks` directory. This approach ensures: -- Tests run without blockchain connections -- Consistent test results -- Fast test execution -- CI/CD compatibility +Tests mock the modules a route depends on — the chain class, the connector class, the +services — and then drive the route through `app.inject`. Real Gateway code runs; only +the blockchain is absent. -### Mock File Naming Convention +Shared mock modules live in `test/mocks` and `test/helpers`. A suite that builds the +whole app imports `test/mocks/app-mocks` **before** importing the app: -| Operation | Mock File Name | -|-----------|----------------| -| Chain status | `status.json` | -| Token balances | `balance.json` | -| Token info | `tokens.json` | -| Pool info | `{type}-pool-info.json` | -| Swap quote | `{type}-quote-swap.json` | -| Position info | `{type}-position-info.json` | -| Router operations | `router-{operation}.json` | - -Where `{type}` is either `amm`, `clmm`, or `router`. - -### Updating Mock Responses - -1. **Start Gateway locally**: - ```bash - pnpm start --passphrase=test --dev - ``` +```typescript +import './mocks/app-mocks'; +``` -2. **Make API calls** to get real responses: - ```bash - curl http://localhost:15888/chains/ethereum/status - ``` +### Do not mock the transport -3. **Save responses** in the appropriate mock file: - ```bash - # Example: Save Ethereum status response - curl http://localhost:15888/chains/ethereum/status > test/mocks/chains/ethereum/status.json - ``` +An earlier generation of these tests mocked `axios`, called it, and asserted that the +canned response they had just supplied had the shape they had supplied. Those imported +nothing from `src/` and would have passed against an empty `src/` — they were deleted. +If a test does not run Gateway code, it is not testing Gateway. -4. **Verify tests** pass with updated mocks: - ```bash - GATEWAY_TEST_MODE=dev jest --runInBand test/chains/ethereum.test.js - ``` +Fixture JSON captured from a live server is gone for the same reason: it goes stale +silently, and a test that asserts against it is asserting about the day it was +captured. Build the response the mocked module returns in the test file, where the +reader can see it. ## Writing Tests ### Test Structure Example -```javascript -// test/connectors/uniswap/amm.test.js -describe('Uniswap AMM Routes', () => { - const mockApp = { - inject: (options) => { - // Mock implementation - } - }; - - beforeEach(() => { - // Setup mocks - }); +```typescript +// test/connectors/orca/clmm-routes/collectFees.test.ts +import { Solana } from '../../../../src/chains/solana/solana'; +import { Orca } from '../../../../src/connectors/orca/orca'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/orca/orca'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { collectFeesRoute } = await import('../../../../src/trading/trading-clmm-routes/collect-fees'); + await server.register(collectFeesRoute); + return server; +}; + +describe('POST /collect-fees (orca)', () => { + it('collects fees and reports the amounts and the pool', async () => { + // Mock what the connector actually calls — not a method it never reaches. + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + (Solana.getInstance as jest.Mock).mockResolvedValue({ + sendAndConfirmTransactionForWallet: jest.fn().mockResolvedValue({ signature: 'sig123' }), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [0.1, 20] }), + }); - it('should return pool information', async () => { - const response = await mockApp.inject({ - method: 'GET', - url: '/connectors/uniswap/amm/pool-info', - query: { - chain: 'ethereum', - network: 'mainnet', - tokenA: 'USDC', - tokenB: 'WETH' - } + const app = await buildApp(); + const response = await app.inject({ + method: 'POST', + url: '/collect-fees', + payload: { chainNetwork: 'solana-mainnet-beta', connector: 'orca', positionAddress: POSITION }, }); expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ - poolAddress: expect.any(String), - token0: expect.any(String), - token1: expect.any(String) - }); + expect(response.json()).toMatchObject({ status: 1, data: { baseFeeAmountCollected: 0.1 } }); }); }); ``` +Use `fastifyWithTypeProvider()` from `test/utils/testUtils` rather than bare +`Fastify()`: it registers the custom schema keywords (`x-connectors`) and the `decimal` +format, which AJV's strict mode rejects otherwise. + ### Testing Best Practices -1. **Use descriptive test names** that explain what is being tested -2. **Test both success and error cases** -3. **Verify response structure** matches TypeBox schemas -4. **Mock external dependencies** (blockchain calls, API requests) -5. **Keep tests isolated** - each test should be independent -6. **Use beforeEach/afterEach** for setup and cleanup +1. **Assert one outcome, not a set.** `expect([200, 400, 500]).toContain(status)` asserts + nothing — it passes whether the route works, rejects, or crashes. If you cannot say + which status a case produces, the test does not yet know what it is testing. +2. **Mock what the code actually calls.** Mocking a method the connector never reaches + leaves the real SDK on the path, the route 500s, and a hedged assertion hides it. + Check the call site before writing the mock. +3. **Use descriptive test names** that explain what is being tested +4. **Test both success and error cases** +5. **Verify response structure** matches TypeBox schemas +6. **Keep tests isolated** - each test should be independent +7. **Use beforeEach/afterEach** for setup and cleanup ### Coverage Requirements @@ -227,9 +186,8 @@ describe('Uniswap AMM Routes', () => { - Increase timeout in specific test: `jest.setTimeout(30000)` - Check for unresolved promises -2. **Mock data mismatch** - - Update mock files with current API responses - - Verify mock file paths are correct +2. **AJV strict-mode errors about an unknown keyword or format** + - Build the server with `fastifyWithTypeProvider()`, not bare `Fastify()` 3. **Module not found errors** - Clear Jest cache: `pnpm test:clear-cache` diff --git a/test/app.integration.test.ts b/test/app.integration.test.ts index 042f88723c..7eb131375d 100644 --- a/test/app.integration.test.ts +++ b/test/app.integration.test.ts @@ -4,6 +4,13 @@ import { FastifyInstance } from 'fastify'; import './mocks/app-mocks'; import { gatewayApp } from '../src/app'; +import { AMM_CONNECTORS, CLMM_CONNECTORS, ROUTER_CONNECTORS } from '../src/trading/connector-registry'; + +// The route table is now unified: the trading type is a path segment and the +// connector is a parameter, so there is one set of routes rather than one set per +// connector. These tests guard that shape — that every connector Gateway advertises +// is reachable through the surface for its trading type, that each unified route is +// registered, and that the per-connector paths they replaced are gone. describe('App Integration - Route Registration', () => { let fastify: FastifyInstance; @@ -17,144 +24,120 @@ describe('App Integration - Route Registration', () => { await fastify.close(); }); - describe('Connector Route Structure', () => { - it('should register routes based on connector trading types', async () => { - // Get the list of connectors and their trading types - const connectorsResponse = await fastify.inject({ - method: 'GET', - url: '/config/connectors', - }); - - const { connectors } = JSON.parse(connectorsResponse.body); - - // Test each connector has the expected routes based on trading types - for (const connector of connectors) { - const { name, trading_types } = connector; - - // Test router routes - if (trading_types.includes('router')) { - // Test quote-swap for all router connectors - const routerResponse = await fastify.inject({ - method: 'GET', - url: `/connectors/${name}/router/quote-swap`, - }); - // Should not be 404 if the route exists - expect(routerResponse.statusCode).not.toBe(404); - } - - // Test AMM routes - if (trading_types.includes('amm')) { - const ammResponse = await fastify.inject({ - method: 'GET', - url: `/connectors/${name}/amm/pool-info`, - }); - expect(ammResponse.statusCode).not.toBe(404); - } - - // Test CLMM routes - if (trading_types.includes('clmm')) { - const clmmResponse = await fastify.inject({ - method: 'GET', - url: `/connectors/${name}/clmm/pool-info`, - }); - expect(clmmResponse.statusCode).not.toBe(404); + const connectors = async () => { + const response = await fastify.inject({ method: 'GET', url: '/config/connectors' }); + return JSON.parse(response.body).connectors as Array<{ name: string; trading_types: string[] }>; + }; + + describe('Connector coverage', () => { + it('backs every advertised trading type with a unified surface', async () => { + const registries: Record = { + router: ROUTER_CONNECTORS, + clmm: CLMM_CONNECTORS, + amm: AMM_CONNECTORS, + }; + + for (const { name, trading_types } of await connectors()) { + for (const type of trading_types) { + expect(registries[type]).toContain(name); } } }); - }); - - describe('Trading Type Validation', () => { - it('should return valid trading types for all connectors', async () => { - const response = await fastify.inject({ - method: 'GET', - url: '/config/connectors', - }); - - const data = JSON.parse(response.body); - // Validate all connectors have valid trading types - data.connectors.forEach((connector: any) => { - expect(connector.trading_types).toBeDefined(); + it('advertises only valid, non-duplicated trading types', async () => { + for (const connector of await connectors()) { expect(Array.isArray(connector.trading_types)).toBe(true); expect(connector.trading_types.length).toBeGreaterThan(0); + connector.trading_types.forEach((type) => expect(['router', 'amm', 'clmm']).toContain(type)); + expect(new Set(connector.trading_types).size).toBe(connector.trading_types.length); + } + }); + }); - // All trading types should be valid - connector.trading_types.forEach((type: string) => { - expect(['router', 'amm', 'clmm']).toContain(type); - }); + describe('Unified route table', () => { + // Ask the router directly. Injecting and checking for a non-404 would be wrong: + // a registered route can legitimately answer 404 (an unresolvable pool, say). + const registered = (method: 'GET' | 'POST', url: string) => (fastify as any).hasRoute({ method, url }); + + it.each([ + ['GET', '/trading/router/quote-swap'], + ['POST', '/trading/router/execute-swap'], + ['POST', '/trading/router/execute-quote'], + ['GET', '/trading/clmm/quote-swap'], + ['POST', '/trading/clmm/execute-swap'], + ['GET', '/trading/clmm/pool-info'], + ['GET', '/trading/clmm/position-info'], + ['GET', '/trading/clmm/positions-owned'], + ['GET', '/trading/clmm/quote-liquidity'], + ['GET', '/trading/clmm/fetch-pools'], + ['POST', '/trading/clmm/open'], + ['POST', '/trading/clmm/add'], + ['POST', '/trading/clmm/remove'], + ['POST', '/trading/clmm/collect-fees'], + ['POST', '/trading/clmm/close'], + ['POST', '/trading/clmm/create-pool'], + ['GET', '/trading/amm/quote-swap'], + ['POST', '/trading/amm/execute-swap'], + ['GET', '/trading/amm/pool-info'], + ['GET', '/trading/amm/position-info'], + ['GET', '/trading/amm/positions-owned'], + ['GET', '/trading/amm/quote-liquidity'], + ['POST', '/trading/amm/add'], + ['POST', '/trading/amm/remove'], + ['POST', '/trading/amm/create-pool'], + ] as Array<['GET' | 'POST', string]>)('registers %s %s', async (method, url) => { + expect(registered(method, url)).toBe(true); + }); - // No duplicates - const uniqueTypes = [...new Set(connector.trading_types)]; - expect(uniqueTypes.length).toBe(connector.trading_types.length); - }); + // Two routes the AMM surface deliberately does not have. `open` was a synonym for + // `add` without a position address, and `close` for `remove` at 100% — which now + // closes the position account itself, so nothing is lost by their absence. Asserted + // so re-adding one is a decision rather than a drift back. + it.each([ + ['POST', '/trading/amm/open'], + ['POST', '/trading/amm/close'], + ] as Array<['GET' | 'POST', string]>)('does not register %s %s', async (method, url) => { + expect(registered(method, url)).toBe(false); }); - }); - describe('Route Structure Validation', () => { - it('should return 404 for unsupported trading type routes', async () => { - // Get connector information - const connectorsResponse = await fastify.inject({ - method: 'GET', - url: '/config/connectors', - }); - - const { connectors } = JSON.parse(connectorsResponse.body); - - // Test that connectors without certain trading types return 404 - for (const connector of connectors) { - const { name, trading_types } = connector; - - // Test router routes if not supported - if (!trading_types.includes('router')) { - const response = await fastify.inject({ - method: 'POST', - url: `/connectors/${name}/router/quote`, - payload: { - chain: connector.chain, - network: connector.networks[0], - baseToken: 'TEST', - quoteToken: 'TEST2', - amount: 1, - side: 'SELL', - }, - }); - expect(response.statusCode).toBe(404); - } + it.each([ + ['GET', '/chains/solana/status'], + ['GET', '/chains/ethereum/status'], + ['GET', '/chains/solana/estimate-gas'], + ['POST', '/chains/solana/balances'], + ['POST', '/chains/ethereum/poll'], + ['POST', '/chains/solana/wrap'], + ['POST', '/chains/solana/unwrap'], + // EVM-only operations keep chain-specific paths rather than 400ing on Solana. + ['POST', '/chains/ethereum/allowances'], + ['POST', '/chains/ethereum/approve'], + ] as Array<['GET' | 'POST', string]>)('registers %s %s', async (method, url) => { + expect(registered(method, url)).toBe(true); + }); - // Test AMM routes if not supported - if (!trading_types.includes('amm')) { - const response = await fastify.inject({ - method: 'POST', - url: `/connectors/${name}/amm/quote`, - payload: { - chain: connector.chain, - network: connector.networks[0], - baseToken: 'TEST', - quoteToken: 'TEST2', - amount: 1, - side: 'SELL', - }, - }); - expect(response.statusCode).toBe(404); - } + it('serves chain routes for any chain through one parameterized path', async () => { + // Not a per-chain registration: the path matches for any chain and an unknown one + // is rejected with a 400, rather than 404ing at the router. The rejection now comes + // from the `chain` parameter's enum — added so Swagger renders it as a dropdown — + // which fires before the handler, so the message is the same schema-validation one + // an unknown `connector` produces rather than resolveChain's prose. The allowed + // chains are in the spec and the dropdown. + const response = await fastify.inject({ method: 'GET', url: '/chains/dogecoin/status' }); + expect(response.statusCode).toBe(400); + expect(response.json().message).toContain('must be equal to one of the allowed values'); + }); + }); - // Test CLMM routes if not supported - if (!trading_types.includes('clmm')) { - const response = await fastify.inject({ - method: 'POST', - url: `/connectors/${name}/clmm/quote`, - payload: { - chain: connector.chain, - network: connector.networks[0], - baseToken: 'TEST', - quoteToken: 'TEST2', - amount: 1, - side: 'SELL', - }, - }); - expect(response.statusCode).toBe(404); - } - } + describe('Replaced routes are gone', () => { + it.each([ + '/connectors/jupiter/router/quote-swap', + '/connectors/meteora/clmm/pool-info', + '/connectors/raydium/amm/pool-info', + '/connectors/orca/clmm/fetch-pools', + '/trading/swap/quote', + ])('404s on %s', async (url) => { + const response = await fastify.inject({ method: 'GET', url }); + expect(response.statusCode).toBe(404); }); }); }); diff --git a/test/chains/ethereum/ethereum.test.js b/test/chains/ethereum/ethereum.test.js deleted file mode 100644 index 92f410c7c0..0000000000 --- a/test/chains/ethereum/ethereum.test.js +++ /dev/null @@ -1,526 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CHAIN = 'ethereum'; -const NETWORK = 'base'; // Only test Base network -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; -const TEST_SPENDER = '0xC36442b4a4522E871399CD717aBDD847Ab11FE88'; // Uniswap V3 Position Manager -const TEST_TOKEN_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - // Use mocks from the same directory - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate balance response structure -function validateBalanceResponse(response) { - return ( - response && - typeof response.network === 'string' && - typeof response.wallet === 'string' && - Array.isArray(response.balances) && - response.balances.every( - (balance) => - typeof balance.symbol === 'string' && - typeof balance.address === 'string' && - typeof balance.decimals === 'number' && - typeof balance.name === 'string' && - typeof balance.balance === 'string', - ) - ); -} - -// Function to validate tokens response structure -function validateTokensResponse(response) { - return ( - response && - typeof response.network === 'string' && - Array.isArray(response.tokens) && - response.tokens.every( - (token) => - typeof token.symbol === 'string' && - typeof token.address === 'string' && - typeof token.decimals === 'number' && - typeof token.name === 'string', - ) - ); -} - -// Function to validate status response structure -function validateStatusResponse(response) { - return ( - response && - typeof response.network === 'string' && - typeof response.isConnected === 'boolean' && - (response.chainId === undefined || typeof response.chainId === 'number') && - (response.latestBlock === undefined || typeof response.latestBlock === 'number') && - (response.gasPrice === undefined || typeof response.gasPrice === 'string') && - (response.nativeCurrency === undefined || - (typeof response.nativeCurrency.name === 'string' && - typeof response.nativeCurrency.symbol === 'string' && - typeof response.nativeCurrency.decimals === 'number')) - ); -} - -// Function to validate allowances response structure -function validateAllowancesResponse(response) { - return ( - response && - typeof response.spender === 'string' && - typeof response.approvals === 'object' && - Object.values(response.approvals).every((value) => typeof value === 'string') - ); -} - -// Function to validate approve response structure -function validateApproveResponse(response) { - return ( - response && - typeof response.tokenAddress === 'string' && - typeof response.spender === 'string' && - typeof response.amount === 'string' && - typeof response.nonce === 'number' && - typeof response.signature === 'string' && - typeof response.approval === 'object' && - typeof response.approval.data === 'string' && - typeof response.approval.to === 'string' - ); -} - -// Tests -describe('Ethereum Chain Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Balance Endpoint', () => { - test('returns and validates wallet balances', async () => { - // Load mock response - const mockResponse = loadMockResponse('balance'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/balances`, { - params: { - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['ETH', 'USDC', 'WETH'], - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateBalanceResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.network).toBe(NETWORK); - expect(response.data.wallet).toBe(TEST_WALLET); - expect(response.data.balances).toHaveLength(3); // ETH, USDC, and WETH - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/balances`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['ETH', 'USDC', 'WETH'], - }), - }), - ); - }); - - test('handles error response for invalid wallet', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Invalid wallet address', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/chains/${CHAIN}/balances`, { - params: { - network: NETWORK, - wallet: 'invalidwallet', - tokens: ['ETH'], - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'Invalid wallet address', - }, - }, - }); - }); - }); - - describe('Tokens Endpoint', () => { - test('returns and validates token list', async () => { - // Load mock response - const mockResponse = loadMockResponse('tokens'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/tokens`, { - params: { - network: NETWORK, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateTokensResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.network).toBe(NETWORK); - expect(response.data.tokens.length).toBeGreaterThanOrEqual(3); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/tokens`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - }), - }), - ); - }); - }); - - describe('Status Endpoint', () => { - test('returns and validates chain status', async () => { - // Load mock response - const mockResponse = loadMockResponse('status'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/status`, { - params: { - network: NETWORK, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateStatusResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.network).toBe(NETWORK); - expect(response.data.isConnected).toBe(true); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/status`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - }), - }), - ); - }); - }); - - describe('Allowances Endpoint', () => { - test('returns allowances using token symbols', async () => { - // Load mock response - const mockResponse = loadMockResponse('allowances'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/chains/${CHAIN}/allowances`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - tokens: ['USDC', 'DAI'], - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateAllowancesResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.spender).toBe(TEST_SPENDER); - expect(response.data.approvals).toHaveProperty('USDC'); - expect(response.data.approvals).toHaveProperty('DAI'); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/allowances`, - expect.objectContaining({ - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - tokens: ['USDC', 'DAI'], - }), - ); - }); - - test('returns allowances using token addresses', async () => { - // Load mock response - const mockResponse = loadMockResponse('allowances'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request with token addresses - const response = await axios.post(`http://localhost:15888/chains/${CHAIN}/allowances`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - tokens: [TEST_TOKEN_ADDRESS, '0x6B175474E89094C44Da98b954EedeAC495271d0F'], // USDC and DAI addresses - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateAllowancesResponse(response.data)).toBe(true); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/allowances`, - expect.objectContaining({ - tokens: [TEST_TOKEN_ADDRESS, '0x6B175474E89094C44Da98b954EedeAC495271d0F'], - }), - ); - }); - - test('handles error when no tokens are found', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'None of the provided tokens were found: INVALID_TOKEN', - code: 400, - }, - }, - }); - - // Make the request with invalid token - await expect( - axios.post(`http://localhost:15888/chains/${CHAIN}/allowances`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - tokens: ['INVALID_TOKEN'], - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: expect.stringContaining('None of the provided tokens were found'), - }, - }, - }); - }); - }); - - describe('Approve Endpoint', () => { - test('approves token using token symbol', async () => { - // Load mock response - const mockResponse = loadMockResponse('approve'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/chains/${CHAIN}/approve`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - token: 'USDC', - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateApproveResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.tokenAddress).toBe(TEST_TOKEN_ADDRESS); - expect(response.data.spender).toBe(TEST_SPENDER); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/approve`, - expect.objectContaining({ - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - token: 'USDC', - }), - ); - }); - - test('approves token using token address', async () => { - // Load mock response - const mockResponse = loadMockResponse('approve'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request with token address - const response = await axios.post(`http://localhost:15888/chains/${CHAIN}/approve`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - token: TEST_TOKEN_ADDRESS, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateApproveResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.tokenAddress).toBe(TEST_TOKEN_ADDRESS); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/approve`, - expect.objectContaining({ - token: TEST_TOKEN_ADDRESS, - }), - ); - }); - - test('approves token with custom amount', async () => { - // Load mock response - const mockResponse = { - ...loadMockResponse('approve'), - amount: '1000000', // 1 USDC with 6 decimals - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request with amount - const response = await axios.post(`http://localhost:15888/chains/${CHAIN}/approve`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - token: 'USDC', - amount: '1', - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateApproveResponse(response.data)).toBe(true); - - // Verify amount was set correctly - expect(response.data.amount).toBe('1000000'); - }); - - test('handles error for invalid token', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Token not supported and not a valid Ethereum address: INVALID_TOKEN', - code: 400, - }, - }, - }); - - // Make the request with invalid token - await expect( - axios.post(`http://localhost:15888/chains/${CHAIN}/approve`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - token: 'INVALID_TOKEN', - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: expect.stringContaining('Token not supported'), - }, - }, - }); - }); - - test('handles error for invalid token address', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Invalid token address or not an ERC20 token: 0x1234567890abcdef', - code: 400, - }, - }, - }); - - // Make the request with invalid address format - await expect( - axios.post(`http://localhost:15888/chains/${CHAIN}/approve`, { - network: NETWORK, - address: TEST_WALLET, - spenderAddress: TEST_SPENDER, - token: '0x1234567890abcdef', // Invalid address format - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: expect.stringContaining('Invalid token address'), - }, - }, - }); - }); - }); -}); diff --git a/test/chains/ethereum/mocks/allowances.json b/test/chains/ethereum/mocks/allowances.json deleted file mode 100644 index 8a2d8d0980..0000000000 --- a/test/chains/ethereum/mocks/allowances.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "spender": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88", - "approvals": { - "USDC": "10000000000", - "DAI": "999999999999999999999999999" - } -} \ No newline at end of file diff --git a/test/chains/ethereum/mocks/approve.json b/test/chains/ethereum/mocks/approve.json deleted file mode 100644 index ff6c69c56b..0000000000 --- a/test/chains/ethereum/mocks/approve.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "spender": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88", - "amount": "115792089237316195423570985008687907853269984665640564039457584007913129639935", - "nonce": 42, - "signature": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "approval": { - "data": "0x095ea7b3000000000000000000000000c36442b4a4522e871399cd717abdd847ab11fe88ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "maxPriorityFeePerGas": "1500000000", - "maxFeePerGas": "3000000000", - "gasLimit": "60000", - "value": "0" - } -} \ No newline at end of file diff --git a/test/chains/ethereum/mocks/balance.json b/test/chains/ethereum/mocks/balance.json deleted file mode 100644 index ecf2c4e653..0000000000 --- a/test/chains/ethereum/mocks/balance.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "network": "base", - "wallet": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "balances": [ - { - "symbol": "ETH", - "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "decimals": 18, - "name": "Ethereum", - "balance": "2.5" - }, - { - "symbol": "USDC", - "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "decimals": 6, - "name": "USD Coin", - "balance": "1500.00" - }, - { - "symbol": "WETH", - "address": "0x4200000000000000000000000000000000000006", - "decimals": 18, - "name": "Wrapped Ethereum", - "balance": "1.0" - } - ] -} \ No newline at end of file diff --git a/test/chains/ethereum/mocks/status.json b/test/chains/ethereum/mocks/status.json deleted file mode 100644 index ad105c6c7e..0000000000 --- a/test/chains/ethereum/mocks/status.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "network": "base", - "chainId": 8453, - "rpcUrl": "https://mainnet.base.org", - "isConnected": true, - "latestBlock": 12345678, - "gasPrice": "1.5", - "nativeCurrency": { - "name": "Ethereum", - "symbol": "ETH", - "decimals": 18 - } -} \ No newline at end of file diff --git a/test/chains/ethereum/mocks/tokens.json b/test/chains/ethereum/mocks/tokens.json deleted file mode 100644 index 7eb8c30cda..0000000000 --- a/test/chains/ethereum/mocks/tokens.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "network": "base", - "tokens": [ - { - "name": "Ethereum", - "symbol": "ETH", - "decimals": 18, - "address": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" - }, - { - "name": "Wrapped Ethereum", - "symbol": "WETH", - "decimals": 18, - "address": "0x4200000000000000000000000000000000000006" - }, - { - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" - }, - { - "name": "Tether USD", - "symbol": "USDT", - "decimals": 6, - "address": "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA" - }, - { - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "address": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb" - } - ] -} \ No newline at end of file diff --git a/test/chains/ethereum/mocks/wrap.json b/test/chains/ethereum/mocks/wrap.json deleted file mode 100644 index 312493ae24..0000000000 --- a/test/chains/ethereum/mocks/wrap.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "nonce": 42, - "signature": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "fee": "0.000021", - "amount": "0.1", - "wrappedAddress": "0x4200000000000000000000000000000000000006", - "nativeToken": "ETH", - "wrappedToken": "WETH", - "tx": { - "data": "0xd0e30db0", - "to": "0x4200000000000000000000000000000000000006", - "maxPriorityFeePerGas": null, - "maxFeePerGas": null, - "gasLimit": "21000", - "value": "100000000000000000" - } -} \ No newline at end of file diff --git a/test/chains/ethereum/routes/estimate-gas.test.ts b/test/chains/ethereum/routes/estimate-gas.test.ts index 53273d4f94..b17fda9250 100644 --- a/test/chains/ethereum/routes/estimate-gas.test.ts +++ b/test/chains/ethereum/routes/estimate-gas.test.ts @@ -5,6 +5,7 @@ import '../../../mocks/app-mocks'; import { gatewayApp } from '../../../../src/app'; import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { parseWire } from '../../../utils/wire'; // Mock the Ethereum class, but keep the real EIP1559_NETWORKS constant // (automocking would replace the array and break the route's network gate) @@ -55,7 +56,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 15.5, @@ -80,7 +81,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(503); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data.error).toBe('ServiceUnavailableError'); expect(data.message).toContain('RPC provider unavailable'); @@ -98,7 +99,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(500); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data.error).toBe('InternalServerError'); expect(data.message).toContain('Failed to estimate gas'); @@ -118,7 +119,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 10.0, @@ -142,7 +143,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); // Verify response schema expect(typeof data.feePerComputeUnit).toBe('number'); @@ -175,10 +176,10 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect({ network, gasType: data.gasType }).toEqual({ network, gasType: 'eip1559' }); - expect(data.maxFeePerGas).toBe(12.5); + expect(Number(data.maxFeePerGas)).toBe(12.5); expect(data.maxPriorityFeePerGas).toBe(0.5); } }); @@ -197,7 +198,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data.gasType).toBe('legacy'); expect(data.maxFeePerGas).toBeUndefined(); @@ -218,7 +219,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data.gasType).toBe('legacy'); }); @@ -232,7 +233,7 @@ describe('Ethereum Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 12.0, diff --git a/test/chains/ethereum/routes/poll.test.ts b/test/chains/ethereum/routes/poll.test.ts new file mode 100644 index 0000000000..fb0b89b32a --- /dev/null +++ b/test/chains/ethereum/routes/poll.test.ts @@ -0,0 +1,83 @@ +// Import shared mocks before importing app +import '../../../mocks/app-mocks'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { pollEthereumTransaction } from '../../../../src/chains/ethereum/routes/poll'; +import { TransactionStatusCode } from '../../../../src/schemas/chain-schema'; + +// Mock the Ethereum class +jest.mock('../../../../src/chains/ethereum/ethereum', () => ({ + Ethereum: { + getInstance: jest.fn(), + }, +})); + +const mockEthereum = Ethereum as jest.Mocked; + +const TX_HASH = '0x' + 'ab'.repeat(32); + +describe('pollEthereumTransaction', () => { + const mockEthereumInstance = { + getCurrentBlockNumber: jest.fn(), + getTransaction: jest.fn(), + getTransactionReceipt: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockEthereum.getInstance.mockResolvedValue(mockEthereumInstance as any); + mockEthereumInstance.getCurrentBlockNumber.mockResolvedValue(21000000); + }); + + it('returns NOT_FOUND when the node does not know the transaction', async () => { + mockEthereumInstance.getTransaction.mockResolvedValue(null); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.NOT_FOUND); + expect(mockEthereumInstance.getTransactionReceipt).not.toHaveBeenCalled(); + }); + + it('returns PENDING while the transaction sits in the mempool', async () => { + mockEthereumInstance.getTransaction.mockResolvedValue({ + hash: TX_HASH, + gasLimit: { toString: () => '21000' }, + value: { toString: () => '0' }, + }); + mockEthereumInstance.getTransactionReceipt.mockResolvedValue(null); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.PENDING); + expect(result.txBlock).toBe(-1); + }); + + it('returns CONFIRMED for a mined transaction with receipt status 1', async () => { + mockEthereumInstance.getTransaction.mockResolvedValue({ + hash: TX_HASH, + gasLimit: { toString: () => '21000' }, + value: { toString: () => '0' }, + }); + mockEthereumInstance.getTransactionReceipt.mockResolvedValue({ status: 1, blockNumber: 20999999, logs: [] }); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.CONFIRMED); + expect(result.txBlock).toBe(20999999); + }); + + it('returns FAILED for a reverted transaction (receipt status 0)', async () => { + // Regression: receipt.status 0 is a number, so the old + // `typeof status === 'number' ? 1 : -1` mapping reported reverts as confirmed. + mockEthereumInstance.getTransaction.mockResolvedValue({ + hash: TX_HASH, + gasLimit: { toString: () => '21000' }, + value: { toString: () => '0' }, + }); + mockEthereumInstance.getTransactionReceipt.mockResolvedValue({ status: 0, blockNumber: 20999999, logs: [] }); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.FAILED); + }); +}); diff --git a/test/chains/ethereum/routes/status.test.ts b/test/chains/ethereum/routes/status.test.ts index 79890d1c51..b9a29e5e15 100644 --- a/test/chains/ethereum/routes/status.test.ts +++ b/test/chains/ethereum/routes/status.test.ts @@ -299,7 +299,11 @@ describe('Ethereum Status Route', () => { expect(responseBody).toHaveProperty('rpcUrl'); }); - it('should return error response on failure with fallback values', async () => { + // The per-chain route used to answer a failed status check with HTTP 500 AND a + // fabricated body ("unavailable", block 0), which a client could not tell apart + // from a real reading without inspecting the status code. The parameterized route + // reports the actual error instead. + it('reports the underlying error instead of fabricating a status body', async () => { // Mock the getInstance to throw an error mockEthereum.getInstance.mockRejectedValue(new Error('Connection failed')); @@ -314,15 +318,8 @@ describe('Ethereum Status Route', () => { expect(response.statusCode).toBe(500); const responseBody = JSON.parse(response.body); - expect(responseBody).toEqual({ - chain: 'ethereum', - network: 'mainnet', - rpcUrl: 'unavailable', - rpcProvider: 'unavailable', - currentBlockNumber: 0, - nativeCurrency: 'ETH', - swapProvider: '', - }); + expect(responseBody).toHaveProperty('statusCode', 500); + expect(responseBody).not.toHaveProperty('currentBlockNumber'); mockError.mockRestore(); }); diff --git a/test/chains/ethereum/token-from-chain.test.ts b/test/chains/ethereum/token-from-chain.test.ts new file mode 100644 index 0000000000..eed19538d3 --- /dev/null +++ b/test/chains/ethereum/token-from-chain.test.ts @@ -0,0 +1,101 @@ +/** + * Resolving an Ethereum token by symbol or address. + * + * The list is the only source for a symbol, but an address identifies a contract that + * can be asked what it is. Until it was asked, every route that describes a pool by its + * token addresses failed on any token the list omitted — Uniswap's pool-info answered + * "Token information not found for pool" for a real pool on real tokens. + * + * The two properties that keep that from becoming a behaviour change everywhere: an + * unknown symbol still resolves to nothing, and a listed token still comes from the list. + */ +import { Ethereum } from '../../../src/chains/ethereum/ethereum'; + +const LISTED = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + chainId: 1, +}; +const PEPE = '0x6982508145454Ce325dDbE47a25d4ec3d2311933'; + +const mockContract = { name: jest.fn(), symbol: jest.fn(), decimals: jest.fn() }; + +// Only the token list and the contract factory are stood in for; getToken itself, and +// the fetchTokenFromChain it falls back to, are the real methods. +const ethereumWith = (tokens: any[]): Ethereum => { + const instance: any = Object.create(Ethereum.prototype); + instance.chainId = 1; + instance.getTokenList = jest.fn().mockResolvedValue(tokens); + instance.getContract = jest.fn().mockReturnValue(mockContract); + (instance as any).chainReadTokens = new Map(); + return instance; +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockContract.name.mockResolvedValue('Pepe'); + mockContract.symbol.mockResolvedValue('PEPE'); + mockContract.decimals.mockResolvedValue(18); +}); + +describe('Ethereum.getToken', () => { + it('returns a listed token without touching the chain', async () => { + const ethereum = ethereumWith([LISTED]); + + await expect(ethereum.getToken('USDC')).resolves.toEqual(LISTED); + await expect(ethereum.getToken(LISTED.address)).resolves.toEqual(LISTED); + expect((ethereum as any).getContract).not.toHaveBeenCalled(); + }); + + it('reads an unlisted address off the chain', async () => { + const ethereum = ethereumWith([LISTED]); + + await expect(ethereum.getToken(PEPE)).resolves.toEqual({ + address: PEPE, + chainId: 1, + decimals: 18, + name: 'Pepe', + symbol: 'PEPE', + }); + }); + + // The containment property. A symbol names nothing the chain can be asked about, so + // every caller that passes one — wrap, unwrap, an approve by symbol — is unaffected. + it('still resolves an unknown symbol to nothing, without a round trip', async () => { + const ethereum = ethereumWith([LISTED]); + + await expect(ethereum.getToken('NOSUCHTOKEN')).resolves.toBeUndefined(); + expect((ethereum as any).getContract).not.toHaveBeenCalled(); + }); + + it('resolves an address that is not an ERC-20 to nothing', async () => { + const ethereum = ethereumWith([LISTED]); + mockContract.symbol.mockRejectedValue(new Error('call revert exception')); + + await expect(ethereum.getToken(PEPE)).resolves.toBeUndefined(); + }); + + // getToken runs in loops — every token of a balance request, every position a wallet + // owns — so an unlisted address must not cost three eth_calls on every pass. + it('reads a given address from the chain only once', async () => { + const ethereum = ethereumWith([LISTED]); + + await ethereum.getToken(PEPE); + await ethereum.getToken(PEPE); + await ethereum.getToken(PEPE); + + expect(mockContract.symbol).toHaveBeenCalledTimes(1); + }); + + // A miss is not remembered: an address with no contract today may have one tomorrow, + // and a process that ran before the deploy should not be wrong until it restarts. + it('does not remember an address that resolved to nothing', async () => { + const ethereum = ethereumWith([LISTED]); + mockContract.symbol.mockRejectedValueOnce(new Error('no contract yet')); + + await expect(ethereum.getToken(PEPE)).resolves.toBeUndefined(); + await expect(ethereum.getToken(PEPE)).resolves.toMatchObject({ symbol: 'PEPE' }); + }); +}); diff --git a/test/chains/ethereum/transaction-confirmation.test.ts b/test/chains/ethereum/transaction-confirmation.test.ts new file mode 100644 index 0000000000..ba5df56a67 --- /dev/null +++ b/test/chains/ethereum/transaction-confirmation.test.ts @@ -0,0 +1,83 @@ +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../src/chains/ethereum/ethereum'; + +// Unit tests for Ethereum.prototype.handleTransactionConfirmation — the single +// confirmation gate every EVM route that sends a transaction goes through. +// +// The contract it has to hold: +// - no receipt (still pending) -> { confirmed: false } carrying the tx hash, never a throw, +// so a caller can reconcile a transaction that lands after the poll window closes. +// - reverted receipt (status 0) -> throws 400 TRANSACTION_FAILED. `receipt.status === 0` +// must never reach a response body, where 0 reads as TransactionStatus.PENDING. +// - confirmed receipt (status 1) -> { confirmed: true } with the receipt and the gas fee +// already converted to native units. +// +// The class constructor is private and pulls in network state, so the method is invoked +// through Ethereum.prototype..call() against a stub carrying only what it reads. + +type Stub = { handleTransactionExecution: jest.Mock }; + +const callHelper = (stub: Stub, tx: any) => (Ethereum.prototype as any).handleTransactionConfirmation.call(stub, tx); + +describe('Ethereum.handleTransactionConfirmation', () => { + it('reports a still-pending transaction as pending and preserves the tx hash', async () => { + const stub: Stub = { handleTransactionExecution: jest.fn().mockResolvedValue(null) }; + + const outcome = await callHelper(stub, { hash: '0xpendinghash' }); + + expect(outcome).toEqual({ confirmed: false, signature: '0xpendinghash' }); + }); + + it('throws 400 TRANSACTION_FAILED for a reverted transaction instead of reporting PENDING', async () => { + const stub: Stub = { + handleTransactionExecution: jest.fn().mockResolvedValue({ + status: 0, + transactionHash: '0xrevertedhash', + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }), + }; + + const error: any = await callHelper(stub, { hash: '0xrevertedhash' }).catch((e: any) => e); + + expect(error.statusCode).toBe(400); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(error.message).toContain('0xrevertedhash'); + expect(error.message).toMatch(/reverted on-chain/); + }); + + it('returns the receipt and the gas fee in native units for a confirmed transaction', async () => { + const receipt = { + status: 1, + transactionHash: '0xconfirmedhash', + logs: [], + // 21,000 gas at 1 gwei = 0.000021 ETH + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }; + const stub: Stub = { handleTransactionExecution: jest.fn().mockResolvedValue(receipt) }; + + const outcome = await callHelper(stub, { hash: '0xconfirmedhash' }); + + expect(outcome).toEqual({ + confirmed: true, + signature: '0xconfirmedhash', + receipt, + fee: 0.000021, + }); + }); + + it('treats a receipt with no status as pending rather than guessing', async () => { + const stub: Stub = { + handleTransactionExecution: jest.fn().mockResolvedValue({ + status: undefined, + transactionHash: '0xnostatushash', + }), + }; + + const outcome = await callHelper(stub, { hash: '0xnostatushash' }); + + expect(outcome).toEqual({ confirmed: false, signature: '0xnostatushash' }); + }); +}); diff --git a/test/chains/ethereum/wallet.test.ts b/test/chains/ethereum/wallet.test.ts index c99925a501..0113b02171 100644 --- a/test/chains/ethereum/wallet.test.ts +++ b/test/chains/ethereum/wallet.test.ts @@ -1,6 +1,23 @@ // Mock fs-extra to prevent actual file writes jest.mock('fs-extra'); +// Importing src/app runs configureGatewayServer() at module scope, which reads the +// certificate passphrase and calls process.exit when none is configured. That happens +// while this file's imports are being evaluated — long before beforeAll could patch +// readPassphrase — so both suites died at load and reported zero tests. jest.mock is +// hoisted above the imports, which is early enough. +jest.mock('../../../src/services/config-manager-cert-passphrase', () => ({ + // The whole namespace, not just readPassphrase: wallet add/remove goes through + // readWalletKey, which delegates to readPassphrase inside the module, so a partial + // mock leaves it undefined and every write turns into a 500. + ConfigManagerCertPassphrase: { + bindings: { _exit: jest.fn() }, + readPassphrase: jest.fn().mockReturnValue('a'), + readWalletKey: jest.fn().mockReturnValue('a'), + }, +})); +jest.mock('../../../src/https', () => ({ getHttpsOptions: jest.fn().mockReturnValue(null) })); + import * as fse from 'fs-extra'; import { gatewayApp } from '../../../src/app'; diff --git a/test/chains/ethereum/wrap.test.js b/test/chains/ethereum/wrap.test.js deleted file mode 100644 index 4d6abe9811..0000000000 --- a/test/chains/ethereum/wrap.test.js +++ /dev/null @@ -1,183 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CHAIN = 'ethereum'; -const NETWORK = 'base'; // Test on Base network -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; -const WRAPPED_ADDRESS = '0x4200000000000000000000000000000000000006'; // WETH on Base - -// Mock API calls -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate wrap response structure -function validateWrapResponse(response) { - return ( - response && - typeof response.nonce === 'number' && - typeof response.signature === 'string' && - typeof response.fee === 'string' && - typeof response.amount === 'string' && - typeof response.wrappedAddress === 'string' && - typeof response.nativeToken === 'string' && - typeof response.wrappedToken === 'string' && - typeof response.tx === 'object' && - typeof response.tx.data === 'string' && - typeof response.tx.to === 'string' && - (response.tx.gasLimit === null || typeof response.tx.gasLimit === 'string') && - typeof response.tx.value === 'string' - ); -} - -// Tests -describe('Ethereum Wrap Native Token Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Wrap Endpoint', () => { - test('wraps native token to wrapped token successfully', async () => { - // Load mock response - const mockResponse = loadMockResponse('wrap'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/chains/${CHAIN}/wrap`, { - network: NETWORK, - address: TEST_WALLET, - amount: '0.1', // 0.1 ETH - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateWrapResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.wrappedAddress).toBe(WRAPPED_ADDRESS); - expect(response.data.amount).toBe('0.1'); - expect(response.data.fee).toBe('0.000021'); - expect(response.data.nativeToken).toBe('ETH'); - expect(response.data.wrappedToken).toBe('WETH'); - expect(response.data.tx.to).toBe(WRAPPED_ADDRESS); - expect(response.data.tx.value).toBe('100000000000000000'); // 0.1 ETH in wei - expect(response.data.signature).toBeTruthy(); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/wrap`, - expect.objectContaining({ - network: NETWORK, - address: TEST_WALLET, - amount: '0.1', - }), - ); - }); - - test('handles error for invalid wallet address', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Invalid Ethereum address format: invalidwallet', - code: 400, - }, - }, - }); - - // Make the request with invalid wallet - await expect( - axios.post(`http://localhost:15888/chains/${CHAIN}/wrap`, { - network: NETWORK, - address: 'invalidwallet', - amount: '0.1', - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: expect.stringContaining('Invalid Ethereum address'), - }, - }, - }); - }); - - test('handles error for invalid amount', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Invalid amount: must be a valid number', - code: 400, - }, - }, - }); - - // Make the request with invalid amount - await expect( - axios.post(`http://localhost:15888/chains/${CHAIN}/wrap`, { - network: NETWORK, - address: TEST_WALLET, - amount: 'invalid', - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: expect.stringContaining('Invalid amount'), - }, - }, - }); - }); - - test('handles error for insufficient funds', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Insufficient funds for transaction. Please ensure you have enough ETH to wrap.', - code: 400, - }, - }, - }); - - // Make the request with amount too large - await expect( - axios.post(`http://localhost:15888/chains/${CHAIN}/wrap`, { - network: NETWORK, - address: TEST_WALLET, - amount: '1000000', // Very large amount - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: expect.stringContaining('Insufficient funds'), - }, - }, - }); - }); - }); -}); diff --git a/test/chains/solana/account-lifecycle.test.ts b/test/chains/solana/account-lifecycle.test.ts new file mode 100644 index 0000000000..5948ebc8c6 --- /dev/null +++ b/test/chains/solana/account-lifecycle.test.ts @@ -0,0 +1,186 @@ +import { NATIVE_MINT } from '@solana/spl-token'; + +import { accountLifecycleSol, liquidityWithoutRent } from '../../../src/chains/solana/solana.utils'; + +// Every case here is a real mainnet transaction, reduced to the balance arrays the helper +// reads. The numbers are lamports as the chain reported them, so the assertions are +// against what actually happened rather than against a restatement of the arithmetic. +// +// The defect these pin: the routes used to subtract the position account's rent alone. +// A position is more than one account — on DAMM v2 the NFT mint and its token account are +// rent-bearing too, on a PancakeSwap CLMM there are five — so the rest of the rent stayed +// inside the reported deposit or withdrawal and was published as liquidity. + +const OTHER_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; // USDC +const NFT_MINT = '4tBDJGjDGTEzLGdcNo3RmpNGVJz7NEQzoXKMcXBmr3wS'; + +const tx = (accounts: { pre: number; post: number; wrapped?: { pre?: number; post?: number }; mint?: string }[]) => ({ + meta: { + preBalances: accounts.map((a) => a.pre), + postBalances: accounts.map((a) => a.post), + preTokenBalances: accounts + .map((a, accountIndex) => ({ a, accountIndex })) + .filter(({ a }) => a.wrapped?.pre !== undefined || (a.mint && a.pre > 0)) + .map(({ a, accountIndex }) => ({ + accountIndex, + mint: a.mint ?? NATIVE_MINT.toBase58(), + uiTokenAmount: { amount: String(a.wrapped?.pre ?? 1) }, + })), + postTokenBalances: accounts + .map((a, accountIndex) => ({ a, accountIndex })) + .filter(({ a }) => a.wrapped?.post !== undefined || (a.mint && a.post > 0)) + .map(({ a, accountIndex }) => ({ + accountIndex, + mint: a.mint ?? NATIVE_MINT.toBase58(), + uiTokenAmount: { amount: String(a.wrapped?.post ?? 1) }, + })), + }, +}); + +describe('accountLifecycleSol', () => { + // 67ZzMAHv… — closing the last DAMM v2 position. Four accounts closed; the old code + // saw one of them, so 0.0219 SOL was recorded as withdrawn against a real payout of + // 0.0053 — 4.1x. + describe('a DAMM v2 close, which closes four accounts', () => { + const dammClose = tx([ + { pre: 2_568_527_013, post: 2_594_123_858 }, // the wallet + { pre: 4_127_280, post: 0 }, // position NFT mint + { pre: 2_039_280, post: 0, mint: NFT_MINT }, // the NFT's token account + { pre: 3_730_560, post: 0 }, // the position + { pre: 10_413_811, post: 0, wrapped: { pre: 8_374_531 } }, // the wallet's WSOL account + { pre: 605_757_454_563, post: 605_752_155_435 }, // the pool vault + ]); + + it('totals every closed account, not the position alone', () => { + expect(accountLifecycleSol(dammClose).closed).toBeCloseTo(0.020310931, 9); + }); + + it('counts a wrapped balance the wallet already held as returned, but not as rent', () => { + // 0.008374531 WSOL was sitting in that account before the transaction. It comes + // back with the rent and has to leave the liquidity figure, but calling it rent + // would overstate what the position cost to hold. + expect(accountLifecycleSol(dammClose).rentRefunded).toBeCloseTo(0.0119364, 9); + }); + + it('leaves exactly the pool payout behind', () => { + // Wallet delta 0.025596845 + fee 0.000013214 — extractBalanceChangesAndFee adds the + // fee back for the payer — against the vault's own outflow of 0.005299128. + const change = 0.025610059; + const { closed } = accountLifecycleSol(dammClose); + + expect(liquidityWithoutRent(change, NATIVE_MINT, closed)).toBeCloseTo(0.005299128, 9); + }); + }); + + // 2CMNt7Bk… — the first pancakeswap-sol open. Five accounts created, 0.01364856 SOL of + // rent, against 0.009987471 actually deposited: the rent was the larger number. + describe('a PancakeSwap CLMM open, which creates five accounts', () => { + const clmmOpen = tx([ + { pre: 2_573_201_337, post: 2_549_475_306 }, // the wallet + { pre: 0, post: 4_231_680 }, // the position + { pre: 0, post: 2_039_280, wrapped: { post: 0 } }, // a fresh WSOL account, spent to zero + { pre: 0, post: 2_074_080, mint: '3ttZ9NfqLnZmnDwazMvnCMsLpx58grFS6xvE8xYNhiRd' }, // NFT account + { pre: 0, post: 2_456_880 }, // the shared protocol position + { pre: 0, post: 2_846_640 }, // a tick array this range was first to touch + { pre: 8_337_552_804_054, post: 8_337_562_791_525 }, // the pool vault + ]); + + it('totals every created account', () => { + expect(accountLifecycleSol(clmmOpen).opened).toBeCloseTo(0.01364856, 9); + }); + + it('leaves exactly what reached the pool vault', () => { + // Wallet delta 0.023726031 less the 0.00009 gas the helper reports separately. + const { opened } = accountLifecycleSol(clmmOpen); + + expect(liquidityWithoutRent(-0.023636031, NATIVE_MINT, opened)).toBeCloseTo(0.009987471, 9); + }); + + it('reports rent equal to the total when nothing was left wrapped', () => { + const { opened, rentLocked } = accountLifecycleSol(clmmOpen); + + expect(rentLocked).toBeCloseTo(opened, 12); + }); + }); + + // Cy8wiJaS… — the close of that same position, and the sharpest case in the set: the + // whole native movement was rent, so the number published as liquidity withdrawn was + // rent to the lamport. The SOL itself never touched the native balance (GW-30). + describe('a PancakeSwap CLMM close that left its SOL wrapped', () => { + const clmmClose = tx([ + { pre: 2_549_410_306, post: 2_558_477_706 }, // the wallet + { pre: 2_074_080, post: 0, mint: '3ttZ9NfqLnZmnDwazMvnCMsLpx58grFS6xvE8xYNhiRd' }, // NFT account + { pre: 2_846_640, post: 0 }, // the tick array + { pre: 4_231_680, post: 0 }, // the position + { pre: 2_039_999, post: 10_413_811, wrapped: { pre: 719, post: 8_374_531 } }, // WSOL, not unwrapped + ]); + + it('accounts for the three closed accounts', () => { + expect(accountLifecycleSol(clmmClose).closed).toBeCloseTo(0.0091524, 9); + }); + + it('reports nothing withdrawn natively, because nothing was', () => { + // The reported figure was 0.0091524 — the rent, relabelled. Subtracting it leaves + // the zero that is true of the native balance; the 0.008373812 that came out of the + // pool went to the WSOL account, which is what GW-30's unwrap is for. + const { closed } = accountLifecycleSol(clmmClose); + + expect(liquidityWithoutRent(0.0091524, NATIVE_MINT, closed)).toBe(0); + }); + + it('does not count an account that only changed balance', () => { + // The WSOL account went 0.002039999 -> 0.010413811. It neither opened nor closed, + // so none of it belongs to either total. + const { opened, closed } = accountLifecycleSol(clmmClose); + + expect(opened).toBe(0); + expect(closed).toBeCloseTo(0.0091524, 9); + }); + }); + + describe('accounts that do not move rent', () => { + it('ignores an account created and closed within the same transaction', () => { + // A WSOL account opened to receive a withdrawal and unwrapped in the same + // transaction: its lamports never left the wallet, so neither total may claim them. + const sameTx = tx([ + { pre: 1_000_000_000, post: 1_008_000_000 }, + { pre: 0, post: 0 }, + ]); + + expect(accountLifecycleSol(sameTx)).toEqual({ opened: 0, closed: 0, rentLocked: 0, rentRefunded: 0 }); + }); + + it('ignores a balance that merely changed', () => { + const noLifecycle = tx([ + { pre: 1_000_000_000, post: 900_000_000 }, + { pre: 5, post: 500 }, + ]); + + expect(accountLifecycleSol(noLifecycle).opened).toBe(0); + expect(accountLifecycleSol(noLifecycle).closed).toBe(0); + }); + + it('reads a transaction with no balances as no movement', () => { + expect(accountLifecycleSol({ meta: {} })).toEqual({ opened: 0, closed: 0, rentLocked: 0, rentRefunded: 0 }); + }); + + it('does not treat a non-native token balance as wrapped SOL', () => { + // A closed USDC account's whole pre-balance is rent; only WSOL carries a balance + // that is somebody's money rather than the account's deposit. + const usdcClosed = tx([ + { pre: 1_000_000_000, post: 1_002_039_280 }, + { pre: 2_039_280, post: 0, mint: OTHER_MINT }, + ]); + + expect(accountLifecycleSol(usdcClosed).rentRefunded).toBeCloseTo(0.00203928, 9); + }); + }); + + it('reports SOL, not lamports', () => { + // The totals are subtracted from token amounts. Being out by 1e9 would clamp every + // native side to zero without any error. + const one = tx([{ pre: 1_000_000_000, post: 0 }]); + + expect(accountLifecycleSol(one).closed).toBe(1); + }); +}); diff --git a/test/chains/solana/confirmation-helpers.test.ts b/test/chains/solana/confirmation-helpers.test.ts new file mode 100644 index 0000000000..a868dd67e5 --- /dev/null +++ b/test/chains/solana/confirmation-helpers.test.ts @@ -0,0 +1,134 @@ +/** + * Unit tests for the shared route-level confirmation helpers: + * Solana.getConfirmedTransactionData and Solana.handleConfirmation. + * + * Exercised via Function.prototype.call against a hand-built `this` (like the + * sendAndConfirmTransactionForWallet tests) so the branching can be asserted without + * standing up the Solana singleton / RPC. + * + * These pin the G4 contract: existence of txData is never equated with confirmation — + * a landed-but-failed transaction throws TRANSACTION_FAILED, and a just-confirmed + * transaction whose data lags RPC visibility is found via the retrying fetch instead of + * being misreported as PENDING. + */ + +import { Solana } from '../../../src/chains/solana/solana'; + +const buildLandedWithErrorException = (Solana.prototype as any).buildLandedWithErrorException; + +const getConfirmedTransactionData = (Solana.prototype as any).getConfirmedTransactionData as ( + this: unknown, + signature: string, +) => Promise; + +const handleConfirmation = (Solana.prototype as any).handleConfirmation as ( + this: unknown, + signature: string, + txData: any, + tokenIn: string, + tokenOut: string, + walletAddress: string, + side?: 'BUY' | 'SELL', + slippagePct?: number, +) => Promise<{ signature: string; status: number; data?: any }>; + +const WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; +const TOKEN_IN = 'So11111111111111111111111111111111111111112'; +const TOKEN_OUT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +const failedTxData = { + meta: { err: { InstructionError: [1, { Custom: 6001 }] }, logMessages: [] }, +}; + +describe('Solana.getConfirmedTransactionData', () => { + it('uses the retrying fetch and returns the transaction data when it succeeded', async () => { + const fetchWithRetry = jest.fn(async () => ({ meta: { err: null, fee: 5000 } })); + const fakeThis = { _fetchTransactionWithRetry: fetchWithRetry, buildLandedWithErrorException }; + + const txData = await getConfirmedTransactionData.call(fakeThis, 'sig'); + expect(txData).toEqual({ meta: { err: null, fee: 5000 } }); + expect(fetchWithRetry).toHaveBeenCalledWith('sig'); + }); + + it('throws TRANSACTION_FAILED when the transaction landed on-chain with an error', async () => { + const fakeThis = { + _fetchTransactionWithRetry: jest.fn(async () => failedTxData), + buildLandedWithErrorException, + }; + + const error = await getConfirmedTransactionData.call(fakeThis, 'landed-sig').then( + () => { + throw new Error('expected getConfirmedTransactionData to throw'); + }, + (e: any) => e, + ); + expect(error.message).toMatch(/Transaction landed-sig landed on-chain but failed/); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(error.statusCode).toBe(400); + }); + + it('returns null when the transaction is genuinely not visible yet', async () => { + const fakeThis = { _fetchTransactionWithRetry: jest.fn(async () => null), buildLandedWithErrorException }; + await expect(getConfirmedTransactionData.call(fakeThis, 'gone-sig')).resolves.toBeNull(); + }); +}); + +describe('Solana.handleConfirmation', () => { + const confirmedThis = () => ({ + buildLandedWithErrorException, + _fetchTransactionWithRetry: jest.fn(async () => null), + extractBalanceChangesAndFee: jest.fn(async () => ({ balanceChanges: [-0.1, 14.85], fee: 0.000005 })), + }); + + it('reports CONFIRMED with the applied slippagePct echoed in data', async () => { + const fakeThis = confirmedThis(); + const result = await handleConfirmation.call( + fakeThis, + 'sig', + { meta: { err: null } }, + TOKEN_IN, + TOKEN_OUT, + WALLET, + 'SELL', + 0.75, + ); + + expect(result.status).toBe(1); + expect(result.data).toMatchObject({ + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + amountIn: 0.1, + amountOut: 14.85, + slippagePct: 0.75, + }); + // Data was supplied, so no re-fetch happened. + expect(fakeThis._fetchTransactionWithRetry).not.toHaveBeenCalled(); + }); + + it('throws TRANSACTION_FAILED for a landed-but-failed transaction instead of misreporting it', async () => { + const fakeThis = confirmedThis(); + const error = await handleConfirmation.call(fakeThis, 'landed-sig', failedTxData, TOKEN_IN, TOKEN_OUT, WALLET).then( + () => { + throw new Error('expected handleConfirmation to throw'); + }, + (e: any) => e, + ); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(fakeThis.extractBalanceChangesAndFee).not.toHaveBeenCalled(); + }); + + it('re-fetches with retry when the caller has no txData, so a lagging RPC does not yield a false PENDING', async () => { + const fakeThis = confirmedThis(); + fakeThis._fetchTransactionWithRetry = jest.fn(async () => ({ meta: { err: null } })); + + const result = await handleConfirmation.call(fakeThis, 'sig', null, TOKEN_IN, TOKEN_OUT, WALLET); + expect(fakeThis._fetchTransactionWithRetry).toHaveBeenCalledWith('sig'); + expect(result.status).toBe(1); + }); + + it('returns the pending shape only when the transaction is genuinely not found', async () => { + const fakeThis = confirmedThis(); + const result = await handleConfirmation.call(fakeThis, 'pending-sig', null, TOKEN_IN, TOKEN_OUT, WALLET); + expect(result).toEqual({ signature: 'pending-sig', status: 0, data: undefined }); + }); +}); diff --git a/test/chains/solana/mocks/balance.json b/test/chains/solana/mocks/balance.json deleted file mode 100644 index 909174dda6..0000000000 --- a/test/chains/solana/mocks/balance.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "network": "mainnet-beta", - "wallet": "AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD", - "balances": [ - { - "symbol": "SOL", - "address": "So11111111111111111111111111111111111111112", - "decimals": 9, - "name": "Solana", - "balance": "10.5" - }, - { - "symbol": "USDC", - "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "decimals": 6, - "name": "USD Coin", - "balance": "2500.00" - }, - { - "symbol": "USDT", - "address": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", - "decimals": 6, - "name": "Tether USD", - "balance": "1000.00" - }, - { - "symbol": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", - "address": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", - "decimals": 5, - "name": "Unknown Token", - "balance": "50000.00" - } - ] -} \ No newline at end of file diff --git a/test/chains/solana/mocks/status.json b/test/chains/solana/mocks/status.json deleted file mode 100644 index a2ff83cd90..0000000000 --- a/test/chains/solana/mocks/status.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "network": "mainnet-beta", - "isConnected": true, - "latestBlock": 228950121, - "rpcUrl": "https://api.mainnet-beta.solana.com", - "gasPrice": "5000", - "nativeCurrency": { - "name": "Solana", - "symbol": "SOL", - "decimals": 9 - } -} \ No newline at end of file diff --git a/test/chains/solana/mocks/tokens.json b/test/chains/solana/mocks/tokens.json deleted file mode 100644 index 9958662b8c..0000000000 --- a/test/chains/solana/mocks/tokens.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "network": "mainnet-beta", - "tokens": [ - { - "name": "Solana", - "symbol": "SOL", - "decimals": 9, - "address": "So11111111111111111111111111111111111111112" - }, - { - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - }, - { - "name": "Tether USD", - "symbol": "USDT", - "decimals": 6, - "address": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - }, - { - "name": "Wrapped SOL", - "symbol": "WSOL", - "decimals": 9, - "address": "So11111111111111111111111111111111111111112" - } - ] -} \ No newline at end of file diff --git a/test/chains/solana/routes/balances-rate-limit.test.ts b/test/chains/solana/routes/balances-rate-limit.test.ts index 274513c057..a60ff350c0 100644 --- a/test/chains/solana/routes/balances-rate-limit.test.ts +++ b/test/chains/solana/routes/balances-rate-limit.test.ts @@ -112,7 +112,9 @@ describe('Solana Balances Route - Rate Limit Handling', () => { const body = JSON.parse(response.body); expect(body).toMatchObject({ statusCode: 500, - error: 'Internal Server Error', + // The unified chain routes surface errors through the same helper the trading + // routes use, so the envelope's `error` is the error name, not the status text. + error: 'InternalServerError', }); }); diff --git a/test/chains/solana/routes/estimate-gas.test.ts b/test/chains/solana/routes/estimate-gas.test.ts index ac439e37f1..19fb3a2518 100644 --- a/test/chains/solana/routes/estimate-gas.test.ts +++ b/test/chains/solana/routes/estimate-gas.test.ts @@ -5,6 +5,7 @@ import '../../../mocks/app-mocks'; import { gatewayApp } from '../../../../src/app'; import { Solana } from '../../../../src/chains/solana/solana'; +import { parseWire } from '../../../utils/wire'; // Mock the Solana class jest.mock('../../../../src/chains/solana/solana'); @@ -54,7 +55,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 2.5, @@ -84,7 +85,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 0.5, // minPriorityFeePerCU from mock instance config @@ -118,7 +119,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 0.1, // Default fallback value @@ -148,7 +149,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 1.25, @@ -176,7 +177,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); // Verify response schema expect(typeof data.feePerComputeUnit).toBe('number'); @@ -201,7 +202,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 1.5, @@ -227,7 +228,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 100.123456, @@ -253,7 +254,7 @@ describe('Solana Estimate Gas Route', () => { }); expect(response.statusCode).toBe(200); - const data = JSON.parse(response.body); + const data = parseWire(response.body); expect(data).toMatchObject({ feePerComputeUnit: 0, // Should return the actual estimate even if 0 diff --git a/test/chains/solana/routes/poll.test.ts b/test/chains/solana/routes/poll.test.ts new file mode 100644 index 0000000000..f5bd3b422e --- /dev/null +++ b/test/chains/solana/routes/poll.test.ts @@ -0,0 +1,157 @@ +// Import shared mocks before importing app +import '../../../mocks/app-mocks'; + +import { pollSolanaTransaction } from '../../../../src/chains/solana/routes/poll'; +import { Solana } from '../../../../src/chains/solana/solana'; +import { TransactionStatusCode } from '../../../../src/schemas/chain-schema'; + +// Mock the Solana class +jest.mock('../../../../src/chains/solana/solana', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana'), + Solana: { + getInstance: jest.fn(), + }, +})); + +const mockSolana = Solana as jest.Mocked; + +// 88-char base58-ish signature that passes the route's format validation +const VALID_SIGNATURE = '5'.repeat(88); + +describe('pollSolanaTransaction', () => { + const mockSolanaInstance = { + getCurrentBlockNumber: jest.fn(), + getTransaction: jest.fn(), + getTransactionStatusCode: jest.fn(), + getSignatureStatus: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSolana.getInstance.mockResolvedValue(mockSolanaInstance as any); + mockSolanaInstance.getCurrentBlockNumber.mockResolvedValue(365795000); + }); + + it('returns CONFIRMED with fee when the transaction landed without error', async () => { + mockSolanaInstance.getTransaction.mockResolvedValue({ + slot: 365794000, + meta: { fee: 5000, err: null }, + }); + mockSolanaInstance.getTransactionStatusCode.mockResolvedValue(TransactionStatusCode.CONFIRMED); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.CONFIRMED); + expect(result.txBlock).toBe(365794000); + expect(result.fee).toBe(5000 / 1e9); + expect(result.error).toBeNull(); + }); + + it('attributes a failed transaction to the program named in the logs', async () => { + // meta.err carries the code but no program; without the logs the parser cannot + // reach the program's error table and every custom code reports UNKNOWN. + mockSolanaInstance.getTransaction.mockResolvedValue({ + slot: 365794000, + meta: { + fee: 5000, + err: { InstructionError: [0, { Custom: 6018 }] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [1]', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782', + ], + }, + }); + mockSolanaInstance.getTransactionStatusCode.mockResolvedValue(TransactionStatusCode.FAILED); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.FAILED); + expect(result.error).toContain('SLIPPAGE_EXCEEDED'); + expect(result.error).not.toContain('UNKNOWN'); + }); + + describe('null txData (not visible at confirmed commitment)', () => { + beforeEach(() => { + mockSolanaInstance.getTransaction.mockResolvedValue(null); + }); + + it('returns UNCONFIRMED when the cluster has seen the signature', async () => { + mockSolanaInstance.getSignatureStatus.mockResolvedValue(TransactionStatusCode.PENDING); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(mockSolanaInstance.getSignatureStatus).toHaveBeenCalledWith(VALID_SIGNATURE); + expect(result.txStatus).toBe(TransactionStatusCode.PENDING); + expect(result.error).toBeNull(); + }); + + it('returns NOT_FOUND when the signature is unknown to the cluster', async () => { + mockSolanaInstance.getSignatureStatus.mockResolvedValue(TransactionStatusCode.NOT_FOUND); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.NOT_FOUND); + expect(result.error).toBeNull(); + }); + + it('returns FAILED when the signature status carries an error', async () => { + mockSolanaInstance.getSignatureStatus.mockResolvedValue(TransactionStatusCode.FAILED); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.FAILED); + }); + }); + + it('returns NOT_FOUND for a malformed signature, which can never resolve', async () => { + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', 'not-a-signature'); + + expect(result.txStatus).toBe(TransactionStatusCode.NOT_FOUND); + expect(result.error).toContain('INVALID_INPUT'); + expect(mockSolanaInstance.getTransaction).not.toHaveBeenCalled(); + }); + + it('returns UNCONFIRMED (not NOT_FOUND) on a transient RPC error so callers keep polling', async () => { + mockSolanaInstance.getTransaction.mockRejectedValue(new Error('RPC unavailable')); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.PENDING); + expect(result.error).toContain('RPC unavailable'); + }); +}); + +describe('Solana.getSignatureStatus', () => { + const { Solana: RealSolana } = jest.requireActual('../../../../src/chains/solana/solana'); + + const makeInstance = (statusValue: any) => { + const instance = Object.create(RealSolana.prototype); + instance.connection = { + getSignatureStatuses: jest.fn().mockResolvedValue({ value: [statusValue] }), + }; + return instance; + }; + + it('maps a null status to NOT_FOUND', async () => { + const instance = makeInstance(null); + await expect(instance.getSignatureStatus(VALID_SIGNATURE)).resolves.toBe(TransactionStatusCode.NOT_FOUND); + expect(instance.connection.getSignatureStatuses).toHaveBeenCalledWith([VALID_SIGNATURE], { + searchTransactionHistory: true, + }); + }); + + it('maps a status with err to FAILED', async () => { + const instance = makeInstance({ + err: { InstructionError: [2, { Custom: 6018 }] }, + confirmationStatus: 'confirmed', + }); + await expect(instance.getSignatureStatus(VALID_SIGNATURE)).resolves.toBe(TransactionStatusCode.FAILED); + }); + + it('maps a processed (seen but unconfirmed) status to UNCONFIRMED', async () => { + const instance = makeInstance({ err: null, confirmationStatus: 'processed' }); + await expect(instance.getSignatureStatus(VALID_SIGNATURE)).resolves.toBe(TransactionStatusCode.PENDING); + }); +}); diff --git a/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts b/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts index c8a37ead5a..e4dec19616 100644 --- a/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts +++ b/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts @@ -45,6 +45,39 @@ function versionedTx(): VersionedTransaction { return new VersionedTransaction(message); } +describe('Solana.sendAndConfirmTransaction compute simulation', () => { + const sendAndConfirm = (Solana.prototype as any).sendAndConfirmTransaction as ( + this: unknown, + tx: Transaction, + ) => Promise<{ signature: string; fee: number }>; + + it('does not broadcast when the compute-estimation simulation returned an error', async () => { + const _sendAndConfirmRawTransaction = jest.fn(); + const fakeThis = { + config: { defaultComputeUnits: 200000 }, + estimateGasPrice: jest.fn(async () => 0.1), + connection: { + simulateTransaction: jest.fn(async () => ({ + value: { + err: { InstructionError: [0, { Custom: 6018 }] }, + unitsConsumed: 10385, + logs: [ + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [1]', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782', + ], + }, + })), + }, + _sendAndConfirmRawTransaction, + }; + + await expect(sendAndConfirm.call(fakeThis, legacyTx())).rejects.toMatchObject({ + code: 'SLIPPAGE_EXCEEDED', + }); + expect(_sendAndConfirmRawTransaction).not.toHaveBeenCalled(); + }); +}); + describe('Solana.sendAndConfirmTransactionForWallet', () => { it('drops extra signers that carry the wallet pubkey (SDK dummy owner signers)', async () => { // Raydium's TxBuilder appends `owner.signer` to the signers it returns. For non-local @@ -179,6 +212,7 @@ describe('Solana.throwIfLandedWithError / confirmationTimeoutError', () => { const throwIfLandedWithError = (Solana.prototype as any).throwIfLandedWithError as ( this: unknown, signature: string, + txData?: any, ) => Promise; const confirmationTimeoutError = (Solana.prototype as any).confirmationTimeoutError as ( this: unknown, @@ -187,6 +221,7 @@ describe('Solana.throwIfLandedWithError / confirmationTimeoutError', () => { it('surfaces the on-chain program error when a tx lands but fails (not a timeout)', async () => { const fakeThis = { + buildLandedWithErrorException: (Solana.prototype as any).buildLandedWithErrorException, connection: { getTransaction: jest.fn(async () => ({ meta: { @@ -200,7 +235,35 @@ describe('Solana.throwIfLandedWithError / confirmationTimeoutError', () => { }, }; - await expect(throwIfLandedWithError.call(fakeThis, 'landed-sig')).rejects.toThrow(/landed on-chain but failed/); + const error = await throwIfLandedWithError.call(fakeThis, 'landed-sig').then( + () => { + throw new Error('expected throwIfLandedWithError to throw'); + }, + (e: any) => e, + ); + // A landed-but-failed tx paid fees on-chain — it is TRANSACTION_FAILED (4xx, + // non-retryable), not a simulation failure, and the message keeps the signature. + expect(error.message).toMatch(/Transaction landed-sig landed on-chain but failed/); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(error.statusCode).toBe(400); + }); + + it('uses caller-provided txData without a re-fetch and throws the shared landed-but-failed error', async () => { + const getTransaction = jest.fn(); + const fakeThis = { + buildLandedWithErrorException: (Solana.prototype as any).buildLandedWithErrorException, + connection: { getTransaction }, + }; + + const failedTxData = { meta: { err: { InstructionError: [0, 'Custom'] }, logMessages: [] } }; + const error = await throwIfLandedWithError.call(fakeThis as any, 'route-sig', failedTxData).then( + () => { + throw new Error('expected throwIfLandedWithError to throw'); + }, + (e: any) => e, + ); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(getTransaction).not.toHaveBeenCalled(); }); it('returns silently when the transaction succeeded or is missing', async () => { diff --git a/test/chains/solana/solana-error-parser.test.ts b/test/chains/solana/solana-error-parser.test.ts index 7e4ae624d8..cdd2c765ea 100644 --- a/test/chains/solana/solana-error-parser.test.ts +++ b/test/chains/solana/solana-error-parser.test.ts @@ -110,6 +110,37 @@ describe('Solana Error Parser', () => { }); describe('Orca Whirlpool errors', () => { + it('should parse Orca token minimum error 6018 as slippage, not math overflow', () => { + const errorMessage = `Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Orca Whirlpool'); + expect(result.errorCode).toBe(6018); + expect(result.message).toContain('minimum token amount'); + }); + + it('attributes a custom error to the FAILING program, not the first invoked one', () => { + // Simulation-shaped message: full logs open with prelude programs + // (ComputeBudget), and the Whirlpool failure comes later. The parser must + // consult Orca's table (6018 = TokenMinSubceeded → slippage), not fall + // through to the generic map's MATH_OVERFLOW. + const errorMessage = [ + 'Transaction simulation failed: ', + 'Error: {"InstructionError":[3,{"Custom":6018}]}', + 'Program Logs: Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [1]', + 'Program log: Error: TokenMinSubceeded', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782', + ].join('\n'); + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Orca Whirlpool'); + expect(result.errorCode).toBe(6018); + }); + it('should parse Orca slippage error via program ID string match', () => { const errorMessage = `Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x178d`; const result = parseSolanaError(errorMessage); diff --git a/test/chains/solana/solana.test.js b/test/chains/solana/solana.test.js deleted file mode 100644 index e25ef8f0d1..0000000000 --- a/test/chains/solana/solana.test.js +++ /dev/null @@ -1,346 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CHAIN = 'solana'; -const NETWORK = 'mainnet-beta'; // Only test mainnet-beta network -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - // Use mocks from the same directory - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate balance response structure -function validateBalanceResponse(response) { - return ( - response && - typeof response.network === 'string' && - typeof response.wallet === 'string' && - Array.isArray(response.balances) && - response.balances.every( - (balance) => - typeof balance.symbol === 'string' && - typeof balance.address === 'string' && - typeof balance.decimals === 'number' && - typeof balance.name === 'string' && - typeof balance.balance === 'string', - ) - ); -} - -// Function to validate tokens response structure -function validateTokensResponse(response) { - return ( - response && - typeof response.network === 'string' && - Array.isArray(response.tokens) && - response.tokens.every( - (token) => - typeof token.symbol === 'string' && - typeof token.address === 'string' && - typeof token.decimals === 'number' && - typeof token.name === 'string', - ) - ); -} - -// Function to validate status response structure -function validateStatusResponse(response) { - return ( - response && - typeof response.network === 'string' && - typeof response.isConnected === 'boolean' && - (response.latestBlock === undefined || typeof response.latestBlock === 'number') && - (response.gasPrice === undefined || typeof response.gasPrice === 'string') && - (response.nativeCurrency === undefined || - (typeof response.nativeCurrency.name === 'string' && - typeof response.nativeCurrency.symbol === 'string' && - typeof response.nativeCurrency.decimals === 'number')) - ); -} - -// Tests -describe('Solana Chain Tests (Mainnet Beta)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Balance Endpoint', () => { - test('returns and validates wallet balances with token symbols', async () => { - // Load mock response - const mockResponse = loadMockResponse('balance'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/balances`, { - params: { - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['SOL', 'USDC', 'USDT'], - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateBalanceResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.network).toBe(NETWORK); - expect(response.data.wallet).toBe(TEST_WALLET); - expect(response.data.balances).toHaveLength(4); // SOL, USDC, USDT, and the token with abbreviated address - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/balances`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['SOL', 'USDC', 'USDT'], - }), - }), - ); - }); - - test('returns and validates wallet balances with token addresses', async () => { - // Define some known Solana token addresses - const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - const BONK_MINT = 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'; - - // Load mock response - const mockResponse = loadMockResponse('balance'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request with token addresses instead of symbols - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/balances`, { - params: { - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['SOL', USDC_MINT, BONK_MINT], - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateBalanceResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.network).toBe(NETWORK); - expect(response.data.wallet).toBe(TEST_WALLET); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/balances`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['SOL', USDC_MINT, BONK_MINT], - }), - }), - ); - }); - - test('returns and validates wallet balances with mixed token symbols and addresses', async () => { - // Define a known Solana token address - const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - - // Load mock response - const mockResponse = loadMockResponse('balance'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request with mixed token symbols and addresses - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/balances`, { - params: { - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['SOL', 'BONK', USDC_MINT], - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateBalanceResponse(response.data)).toBe(true); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/balances`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['SOL', 'BONK', USDC_MINT], - }), - }), - ); - }); - - test('handles error response for invalid wallet', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Invalid wallet address', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/chains/${CHAIN}/balances`, { - params: { - network: NETWORK, - wallet: 'invalidwallet', - tokens: ['SOL'], - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'Invalid wallet address', - }, - }, - }); - }); - - test('handles error response for invalid token address', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'Invalid token address format', - code: 400, - }, - }, - }); - - // Make the request with an invalid token address - await expect( - axios.get(`http://localhost:15888/chains/${CHAIN}/balances`, { - params: { - network: NETWORK, - wallet: TEST_WALLET, - tokens: ['SOL', 'USDC', 'not-a-valid-address'], - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'Invalid token address format', - }, - }, - }); - }); - }); - - describe('Tokens Endpoint', () => { - test('returns and validates token list', async () => { - // Load mock response - const mockResponse = loadMockResponse('tokens'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/tokens`, { - params: { - network: NETWORK, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateTokensResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.network).toBe(NETWORK); - expect(response.data.tokens.length).toBeGreaterThanOrEqual(3); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/tokens`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - }), - }), - ); - }); - }); - - describe('Status Endpoint', () => { - test('returns and validates chain status', async () => { - // Load mock response - const mockResponse = loadMockResponse('status'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/chains/${CHAIN}/status`, { - params: { - network: NETWORK, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateStatusResponse(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.network).toBe(NETWORK); - expect(response.data.isConnected).toBe(true); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/chains/${CHAIN}/status`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - }), - }), - ); - }); - }); -}); diff --git a/test/chains/solana/token-from-chain.test.ts b/test/chains/solana/token-from-chain.test.ts new file mode 100644 index 0000000000..30757c3c22 --- /dev/null +++ b/test/chains/solana/token-from-chain.test.ts @@ -0,0 +1,116 @@ +/** + * Reading a token's identity from the chain. + * + * Solana keeps only decimals on the mint; the name and symbol are in one of two other + * places, so both are tried. What matters as much as finding them is the cases that + * find nothing: they have to answer "not a token" rather than fail, because the callers + * are recording paths that must not disturb the request that triggered them. + */ +const mockGetAccountInfo = jest.fn(); +const mockGetMint = jest.fn(); +const mockGetTokenMetadata = jest.fn(); + +jest.mock('@solana/spl-token', () => ({ + ...jest.requireActual('@solana/spl-token'), + getMint: (...args: any[]) => mockGetMint(...args), + getTokenMetadata: (...args: any[]) => mockGetTokenMetadata(...args), +})); + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; + +import { Solana } from '../../../src/chains/solana/solana'; + +const MINT = 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'; + +// Only the connection is stood in for; fetchTokenFromChain itself is the real method. +const solanaWith = (connection: any): Solana => { + const instance = Object.create(Solana.prototype); + instance.connection = connection; + return instance; +}; + +const chain = () => solanaWith({ getAccountInfo: mockGetAccountInfo }); + +// A Metaplex metadata account as the program lays it out: 65 bytes of key, update +// authority and mint, then name and symbol as 4-byte lengths followed by null-padded +// bytes of 32 and 10 — the widths the program reserves. +const metadataAccount = (name: string, symbol: string) => { + const data = Buffer.alloc(115); + data.writeUInt32LE(32, 65); + data.write(name, 69, 'utf8'); + data.writeUInt32LE(10, 101); + data.write(symbol, 105, 'utf8'); + return { owner: TOKEN_PROGRAM_ID, data }; +}; + +/** Legacy mint at MINT, its metadata account at the PDA. */ +const legacyMintWithMetadata = (name: string, symbol: string) => + mockGetAccountInfo.mockImplementation(async (key: PublicKey) => + key.toBase58() === MINT ? { owner: TOKEN_PROGRAM_ID } : metadataAccount(name, symbol), + ); + +beforeEach(() => jest.clearAllMocks()); + +describe('Solana.fetchTokenFromChain', () => { + it('reads a Token-2022 mint through its metadata extension', async () => { + mockGetAccountInfo.mockResolvedValue({ owner: TOKEN_2022_PROGRAM_ID }); + mockGetMint.mockResolvedValue({ decimals: 6 }); + mockGetTokenMetadata.mockResolvedValue({ name: 'PayPal USD', symbol: 'PYUSD' }); + + await expect(chain().fetchTokenFromChain(MINT)).resolves.toEqual({ + address: MINT, + chainId: 101, + decimals: 6, + name: 'PayPal USD', + symbol: 'PYUSD', + }); + }); + + // A legacy mint has no extension, so the metadata account is the only source. The + // account's bytes are laid out here exactly as the program writes them. + it('reads a legacy mint through its Metaplex metadata account', async () => { + legacyMintWithMetadata('Bonk', 'BONK'); + mockGetMint.mockResolvedValue({ decimals: 5 }); + + await expect(chain().fetchTokenFromChain(MINT)).resolves.toEqual({ + address: MINT, + chainId: 101, + decimals: 5, + name: 'Bonk', + symbol: 'BONK', + }); + expect(mockGetTokenMetadata).not.toHaveBeenCalled(); + }); + + // Named on purpose: a name is reachable here, so the only thing standing between this + // address and a token record is the unreadable mint. Without that check it would be + // recorded as a real token with zero decimals, which prices every later amount wrong. + it('answers null for a named address whose mint cannot be read', async () => { + legacyMintWithMetadata('Not A Token', 'NOPE'); + mockGetMint.mockRejectedValue(new Error('TokenInvalidAccountOwnerError')); + + await expect(chain().fetchTokenFromChain(MINT)).resolves.toBeNull(); + }); + + it('answers null for a mint with decimals but no name anywhere', async () => { + mockGetAccountInfo.mockImplementation(async (key: PublicKey) => + key.toBase58() === MINT ? { owner: TOKEN_PROGRAM_ID } : null, + ); + mockGetMint.mockResolvedValue({ decimals: 9 }); + + await expect(chain().fetchTokenFromChain(MINT)).resolves.toBeNull(); + }); + + it('answers null for something that is not an address, without a round trip', async () => { + await expect(chain().fetchTokenFromChain('SOL')).resolves.toBeNull(); + expect(mockGetAccountInfo).not.toHaveBeenCalled(); + }); + + it('answers null when the address has no account on chain', async () => { + mockGetAccountInfo.mockResolvedValue(null); + + await expect(chain().fetchTokenFromChain(MINT)).resolves.toBeNull(); + expect(mockGetMint).not.toHaveBeenCalled(); + }); +}); diff --git a/test/chains/solana/wallet.test.ts b/test/chains/solana/wallet.test.ts index 5f5448a2cc..d658357840 100644 --- a/test/chains/solana/wallet.test.ts +++ b/test/chains/solana/wallet.test.ts @@ -1,6 +1,23 @@ // Mock fs-extra to prevent actual file writes jest.mock('fs-extra'); +// Importing src/app runs configureGatewayServer() at module scope, which reads the +// certificate passphrase and calls process.exit when none is configured. That happens +// while this file's imports are being evaluated — long before beforeAll could patch +// readPassphrase — so both suites died at load and reported zero tests. jest.mock is +// hoisted above the imports, which is early enough. +jest.mock('../../../src/services/config-manager-cert-passphrase', () => ({ + // The whole namespace, not just readPassphrase: wallet add/remove goes through + // readWalletKey, which delegates to readPassphrase inside the module, so a partial + // mock leaves it undefined and every write turns into a 500. + ConfigManagerCertPassphrase: { + bindings: { _exit: jest.fn() }, + readPassphrase: jest.fn().mockReturnValue('a'), + readWalletKey: jest.fn().mockReturnValue('a'), + }, +})); +jest.mock('../../../src/https', () => ({ getHttpsOptions: jest.fn().mockReturnValue(null) })); + import { Keypair } from '@solana/web3.js'; import bs58 from 'bs58'; import * as fse from 'fs-extra'; diff --git a/test/config/config-routes-v2.test.ts b/test/config/config-routes-v2.test.ts index 918be243d0..d85981dbda 100644 --- a/test/config/config-routes-v2.test.ts +++ b/test/config/config-routes-v2.test.ts @@ -1,5 +1,3 @@ -import Fastify, { FastifyInstance } from 'fastify'; - // Mock dependencies jest.mock('../../src/services/logger', () => ({ logger: { @@ -26,13 +24,15 @@ jest.mock('fs'); jest.mock('js-yaml'); // Import after mocking +import * as fs from 'fs'; + +import { FastifyInstance } from 'fastify'; import * as yaml from 'js-yaml'; import { configRoutes } from '../../src/config/config.routes'; import { updateConfig, getConfig } from '../../src/config/utils'; import { ConfigManagerV2 } from '../../src/services/config-manager-v2'; - -import * as fs from 'fs'; +import { fastifyWithTypeProvider } from '../utils/testUtils'; describe('Config Routes V2 Tests', () => { let fastify: FastifyInstance; @@ -40,7 +40,7 @@ describe('Config Routes V2 Tests', () => { beforeEach(async () => { // Create a new Fastify instance for each test - fastify = Fastify(); + fastify = fastifyWithTypeProvider(); // Setup ConfigManagerV2 mock with new namespace structure mockConfigManager = { diff --git a/test/config/config-utils.test.ts b/test/config/config-utils.test.ts index 331784e48e..2cbf988cc7 100644 --- a/test/config/config-utils.test.ts +++ b/test/config/config-utils.test.ts @@ -1,5 +1,3 @@ -import Fastify, { FastifyInstance } from 'fastify'; - // Mock dependencies jest.mock('../../src/services/logger', () => ({ logger: { @@ -86,14 +84,17 @@ jest.mock('../../src/services/config-manager-v2', () => ({ })); // Import after mocking +import { FastifyInstance } from 'fastify'; + import { getConfig, updateConfig } from '../../src/config/utils'; import { ConfigManagerV2 } from '../../src/services/config-manager-v2'; +import { fastifyWithTypeProvider } from '../utils/testUtils'; describe('Config Utils - Chain-Network Merge', () => { let fastify: FastifyInstance; beforeEach(async () => { - fastify = Fastify(); + fastify = fastifyWithTypeProvider(); jest.clearAllMocks(); }); diff --git a/test/connectors/0x/router-routes/executeQuote.test.ts b/test/connectors/0x/router-routes/executeQuote.test.ts index 12d1eddd43..0f3d9a670c 100644 --- a/test/connectors/0x/router-routes/executeQuote.test.ts +++ b/test/connectors/0x/router-routes/executeQuote.test.ts @@ -5,6 +5,7 @@ import { ZeroX } from '../../../../src/connectors/0x/0x'; import { quoteCache } from '../../../../src/services/quote-cache'; import { TokenService } from '../../../../src/services/token-service'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); jest.mock('../../../../src/connectors/0x/0x'); @@ -13,7 +14,7 @@ jest.mock('../../../../src/services/token-service'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { executeQuoteRoute } = await import('../../../../src/connectors/0x/router-routes/executeQuote'); + const { executeQuoteRoute } = await import('../../../../src/trading/trading-router-routes/executeQuote'); await server.register(executeQuoteRoute); return server; }; @@ -196,17 +197,18 @@ describe('POST /execute-quote', () => { method: 'POST', url: '/execute-quote', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', walletAddress: '0x1234567890123456789012345678901234567890', quoteId: quoteId, }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('signature', mockReceipt.transactionHash); expect(body).toHaveProperty('status', 1); - expect(body.data).toHaveProperty('amountIn', 0.1); + expect(Number(body.data.amountIn)).toBe(0.1); expect(body.data).toHaveProperty('amountOut', 150); expect(body.data).toHaveProperty('fee', 0.006); expect(body.data).toHaveProperty('baseTokenBalanceChange', 0); @@ -220,14 +222,15 @@ describe('POST /execute-quote', () => { method: 'POST', url: '/execute-quote', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', walletAddress: '0x1234567890123456789012345678901234567890', quoteId: 'non-existent-quote', }, }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should throw error if allowance is insufficient', async () => { @@ -287,14 +290,15 @@ describe('POST /execute-quote', () => { method: 'POST', url: '/execute-quote', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', walletAddress: '0x1234567890123456789012345678901234567890', quoteId: quoteId, }, }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.message).toContain('Insufficient allowance'); expect(mockEthereumInstance.approveERC20).not.toHaveBeenCalled(); }); diff --git a/test/connectors/0x/router-routes/executeSwap.test.ts b/test/connectors/0x/router-routes/executeSwap.test.ts index dafcb4479a..31a4a76516 100644 --- a/test/connectors/0x/router-routes/executeSwap.test.ts +++ b/test/connectors/0x/router-routes/executeSwap.test.ts @@ -1,5 +1,6 @@ import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); @@ -15,7 +16,7 @@ jest.mock('../../../../src/connectors/0x/router-routes/executeQuote', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { executeSwapRoute } = await import('../../../../src/connectors/0x/router-routes/executeSwap'); + const { executeSwapRoute } = await import('../../../../src/trading/trading-router-routes/executeSwap'); await server.register(executeSwapRoute); return server; }; @@ -95,7 +96,8 @@ describe('POST /execute-swap', () => { method: 'POST', url: '/execute-swap', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', walletAddress: '0x1234567890123456789012345678901234567890', baseToken: 'WETH', quoteToken: 'USDC', @@ -106,10 +108,10 @@ describe('POST /execute-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('signature', mockReceipt.transactionHash); expect(body).toHaveProperty('status', 1); - expect(body.data).toHaveProperty('amountIn', 0.1); + expect(Number(body.data.amountIn)).toBe(0.1); expect(body.data).toHaveProperty('amountOut', 150); expect(body.data).toHaveProperty('fee', 0.006); expect(body.data).toHaveProperty('baseTokenBalanceChange', -0.1); @@ -159,7 +161,8 @@ describe('POST /execute-swap', () => { method: 'POST', url: '/execute-swap', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', walletAddress: '0x1234567890123456789012345678901234567890', baseToken: 'WETH', quoteToken: 'USDC', @@ -170,10 +173,10 @@ describe('POST /execute-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('signature', mockReceipt.transactionHash); expect(body).toHaveProperty('status', 1); - expect(body.data).toHaveProperty('amountIn', 150); + expect(Number(body.data.amountIn)).toBe(150); expect(body.data).toHaveProperty('amountOut', 0.1); expect(body.data).toHaveProperty('tokenIn', mockUSDC.address); expect(body.data).toHaveProperty('tokenOut', mockWETH.address); @@ -193,7 +196,8 @@ describe('POST /execute-swap', () => { method: 'POST', url: '/execute-swap', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', walletAddress: '0x1234567890123456789012345678901234567890', baseToken: 'INVALID', quoteToken: 'USDC', @@ -204,7 +208,7 @@ describe('POST /execute-swap', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('message', 'Token not found: INVALID'); }); }); diff --git a/test/connectors/0x/router-routes/quoteSwap.test.ts b/test/connectors/0x/router-routes/quoteSwap.test.ts index b5b075ba59..3eef5e9986 100644 --- a/test/connectors/0x/router-routes/quoteSwap.test.ts +++ b/test/connectors/0x/router-routes/quoteSwap.test.ts @@ -1,6 +1,7 @@ import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; import { ZeroX } from '../../../../src/connectors/0x/0x'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); jest.mock('../../../../src/connectors/0x/0x'); @@ -8,7 +9,7 @@ jest.mock('../../../../src/connectors/0x/0x'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/0x/router-routes/quoteSwap'); + const { quoteSwapRoute } = await import('../../../../src/trading/trading-router-routes/quoteSwap'); await server.register(quoteSwapRoute); return server; }; @@ -75,7 +76,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', baseToken: 'WETH', quoteToken: 'USDC', amount: '0.1', @@ -86,16 +88,16 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId'); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut', 150); expect(body).toHaveProperty('minAmountOut'); expect(body).toHaveProperty('maxAmountIn'); expect(body).toHaveProperty('price'); expect(body).toHaveProperty('priceImpactPct'); - expect(body).toHaveProperty('gasEstimate', '200000'); - expect(body).toHaveProperty('expirationTime'); + // gasEstimate / expirationTime were 0x-specific fields on the per-connector route. + // The unified router response carries the shared quote fields plus quoteId. expect(body).toHaveProperty('tokenIn', mockWETH.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); }); @@ -126,7 +128,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', baseToken: 'WETH', quoteToken: 'USDC', amount: '0.1', @@ -137,9 +140,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId'); - expect(body).toHaveProperty('amountIn', 150); + expect(Number(body.amountIn)).toBe(150); expect(body).toHaveProperty('amountOut', 0.1); expect(body).toHaveProperty('tokenIn', mockUSDC.address); expect(body).toHaveProperty('tokenOut', mockWETH.address); @@ -156,7 +159,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', baseToken: 'INVALID', quoteToken: 'USDC', amount: '0.1', @@ -166,7 +170,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should return indicative price when indicativePrice=true', async () => { @@ -190,7 +194,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', baseToken: 'WETH', quoteToken: 'USDC', amount: '0.1', @@ -201,11 +206,11 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(mockZeroXInstance.getPrice).toHaveBeenCalled(); expect(mockZeroXInstance.getQuote).not.toHaveBeenCalled(); expect(body).toHaveProperty('quoteId', 'indicative-price'); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut', 150); expect(body).not.toHaveProperty('expirationTime'); }); @@ -231,7 +236,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: '0x', baseToken: 'WETH', quoteToken: 'USDC', amount: '0.1', @@ -242,7 +248,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(mockZeroXInstance.getPrice).toHaveBeenCalled(); expect(mockZeroXInstance.getQuote).not.toHaveBeenCalled(); expect(body).toHaveProperty('quoteId', 'indicative-price'); diff --git a/test/connectors/dflow/dflow.routes.test.ts b/test/connectors/dflow/dflow.routes.test.ts deleted file mode 100644 index 5fd0a9e0a3..0000000000 --- a/test/connectors/dflow/dflow.routes.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('DFlow Routes Structure', () => { - const CONNECTOR_NAME = 'dflow'; - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should have appropriate route folders based on trading types', async () => { - const response = await fastify.inject({ - method: 'GET', - url: '/config/connectors', - }); - - const { connectors } = JSON.parse(response.body); - const dflowConfig = connectors.find((c: any) => c.name === CONNECTOR_NAME); - - expect(dflowConfig).toBeDefined(); - expect(dflowConfig.chain).toBe('solana'); - expect(dflowConfig.trading_types).toEqual(['router']); - expect(dflowConfig.networks).toEqual(['mainnet-beta']); - - const connectorPath = path.join(__dirname, `../../../src/connectors/${CONNECTOR_NAME}`); - const routerRoutesPath = path.join(connectorPath, 'router-routes'); - expect(fs.existsSync(routerRoutesPath)).toBe(true); - - const files = fs.readdirSync(routerRoutesPath); - expect(files.some((f) => f.toLowerCase().includes('swap'))).toBe(true); - }); - }); - - describe('Route Registration', () => { - it('should register DFlow router routes at /connectors/dflow/router', async () => { - const routes = fastify.printRoutes(); - - expect(routes).toContain('dflow/router/'); - expect(routes).toContain('quote-swap'); - expect(routes).toContain('execute-swap'); - }); - }); -}); diff --git a/test/connectors/dflow/router-routes/executeQuote.test.ts b/test/connectors/dflow/router-routes/executeQuote.test.ts index b9e41f94a1..0d0bea629f 100644 --- a/test/connectors/dflow/router-routes/executeQuote.test.ts +++ b/test/connectors/dflow/router-routes/executeQuote.test.ts @@ -9,7 +9,7 @@ jest.mock('../../../../src/connectors/dflow/dflow'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { executeQuoteRoute } = await import('../../../../src/connectors/dflow/router-routes/executeQuote'); + const { executeQuoteRoute } = await import('../../../../src/trading/trading-router-routes/executeQuote'); await server.register(executeQuoteRoute); return server; }; @@ -53,6 +53,7 @@ describe('POST /execute-quote (dflow)', () => { const mockSolanaInstance = { sendAndConfirmTransactionForWallet, connection: { getTransaction: jest.fn(async () => ({ meta: {} })) }, + getConfirmedTransactionData: jest.fn(async () => ({ meta: {} })), handleConfirmation: jest.fn(async () => confirmedResult), }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); @@ -73,7 +74,12 @@ describe('POST /execute-quote (dflow)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: WALLET, network: 'mainnet-beta', quoteId: 'dflow-quote-1' }, + body: { + walletAddress: WALLET, + chainNetwork: 'solana-mainnet-beta', + connector: 'dflow', + quoteId: 'dflow-quote-1', + }, }); expect(response.statusCode).toBe(200); @@ -89,7 +95,12 @@ describe('POST /execute-quote (dflow)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: WALLET, network: 'mainnet-beta', quoteId: 'missing-quote' }, + body: { + walletAddress: WALLET, + chainNetwork: 'solana-mainnet-beta', + connector: 'dflow', + quoteId: 'missing-quote', + }, }); expect(response.statusCode).toBe(400); @@ -102,7 +113,7 @@ describe('POST /execute-quote (dflow)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: WALLET, network: 'mainnet-beta', quoteId: 'other-quote' }, + body: { walletAddress: WALLET, chainNetwork: 'solana-mainnet-beta', connector: 'dflow', quoteId: 'other-quote' }, }); expect(response.statusCode).toBe(400); diff --git a/test/connectors/dflow/router-routes/quoteSwap.test.ts b/test/connectors/dflow/router-routes/quoteSwap.test.ts index 0b6be97893..6f6ab4f9ff 100644 --- a/test/connectors/dflow/router-routes/quoteSwap.test.ts +++ b/test/connectors/dflow/router-routes/quoteSwap.test.ts @@ -1,6 +1,7 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { DFlow } from '../../../../src/connectors/dflow/dflow'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/dflow/dflow'); @@ -8,7 +9,7 @@ jest.mock('../../../../src/connectors/dflow/dflow'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/dflow/router-routes/quoteSwap'); + const { quoteSwapRoute } = await import('../../../../src/trading/trading-router-routes/quoteSwap'); await server.register(quoteSwapRoute); return server; }; @@ -72,7 +73,8 @@ describe('GET /quote-swap (dflow)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'dflow', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -82,14 +84,16 @@ describe('GET /quote-swap (dflow)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId'); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut', 15); expect(body).toHaveProperty('price', 150); expect(body).toHaveProperty('tokenIn', mockSOL.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); - expect(body).toHaveProperty('quoteResponse'); + // The connector's raw provider payload (quoteResponse / routerResult) is not part + // of the unified router response schema, which serializes the shared quote fields + // plus quoteId. Assertions on it moved out with the per-connector route. expect(body.approximation).toBeUndefined(); // ExactIn with the base amount in raw units @@ -118,7 +122,8 @@ describe('GET /quote-swap (dflow)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'dflow', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -128,9 +133,9 @@ describe('GET /quote-swap (dflow)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('approximation', true); - expect(body).toHaveProperty('amountIn', 15); + expect(Number(body.amountIn)).toBe(15); expect(body.amountOut).toBeCloseTo(0.0999); // Input is fixed for the approximated ExactIn quote expect(body.maxAmountIn).toBeCloseTo(15); @@ -148,7 +153,8 @@ describe('GET /quote-swap (dflow)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'dflow', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -159,7 +165,7 @@ describe('GET /quote-swap (dflow)', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.message).toContain('ExactIn only'); expect(mockDFlowInstance.getQuote).not.toHaveBeenCalled(); }); @@ -175,7 +181,8 @@ describe('GET /quote-swap (dflow)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'dflow', baseToken: 'INVALID', quoteToken: 'USDC', amount: '0.1', @@ -184,7 +191,7 @@ describe('GET /quote-swap (dflow)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should return 400 if no routes found for SELL', async () => { @@ -198,7 +205,8 @@ describe('GET /quote-swap (dflow)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'dflow', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -207,7 +215,7 @@ describe('GET /quote-swap (dflow)', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.message).toContain('No route found'); }); }); diff --git a/test/connectors/dflow/schemas.test.ts b/test/connectors/dflow/schemas.test.ts index cd9b9515f6..025eda781d 100644 --- a/test/connectors/dflow/schemas.test.ts +++ b/test/connectors/dflow/schemas.test.ts @@ -5,34 +5,6 @@ import * as Base from '../../../src/schemas/router-schema'; describe('DFlow Schema Tests', () => { describe('Schema Superset Validation', () => { - it('DFlowQuoteSwapRequest should be a superset of QuoteSwapRequest', () => { - const baseRequired = Base.QuoteSwapRequest.required || []; - const dflowRequired = DFlow.DFlowQuoteSwapRequest.required || []; - - for (const field of baseRequired) { - expect(dflowRequired).toContain(field); - } - - const baseProps = Object.keys(Base.QuoteSwapRequest.properties); - const dflowProps = Object.keys(DFlow.DFlowQuoteSwapRequest.properties); - - for (const prop of baseProps) { - expect(dflowProps).toContain(prop); - } - - const sampleRequest = { - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.QuoteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(DFlow.DFlowQuoteSwapRequest, sampleRequest)).toBe(true); - }); - it('DFlowQuoteSwapResponse should be a superset of QuoteSwapResponse', () => { const baseRequired = Base.QuoteSwapResponse.required || []; const dflowRequired = DFlow.DFlowQuoteSwapResponse.required || []; @@ -48,68 +20,9 @@ describe('DFlow Schema Tests', () => { expect(dflowProps).toContain(prop); } }); - - it('DFlowExecuteQuoteRequest should be a superset of ExecuteQuoteRequest', () => { - const baseRequired = Base.ExecuteQuoteRequest.required || []; - const dflowRequired = DFlow.DFlowExecuteQuoteRequest.required || []; - - for (const field of baseRequired) { - expect(dflowRequired).toContain(field); - } - - const baseProps = Object.keys(Base.ExecuteQuoteRequest.properties); - const dflowProps = Object.keys(DFlow.DFlowExecuteQuoteRequest.properties); - - for (const prop of baseProps) { - expect(dflowProps).toContain(prop); - } - - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - quoteId: '123e4567-e89b-12d3-a456-426614174000', - }; - - expect(Value.Check(Base.ExecuteQuoteRequest, sampleRequest)).toBe(true); - expect(Value.Check(DFlow.DFlowExecuteQuoteRequest, sampleRequest)).toBe(true); - }); - - it('DFlowExecuteSwapRequest should be a superset of ExecuteSwapRequest', () => { - const baseRequired = Base.ExecuteSwapRequest.required || []; - const dflowRequired = DFlow.DFlowExecuteSwapRequest.required || []; - - for (const field of baseRequired) { - expect(dflowRequired).toContain(field); - } - - const baseProps = Object.keys(Base.ExecuteSwapRequest.properties); - const dflowProps = Object.keys(DFlow.DFlowExecuteSwapRequest.properties); - - for (const prop of baseProps) { - expect(dflowProps).toContain(prop); - } - - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.ExecuteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(DFlow.DFlowExecuteSwapRequest, sampleRequest)).toBe(true); - }); }); describe('DFlow-specific Fields', () => { - it('DFlowQuoteSwapRequest should include the BUY approximation flag', () => { - const props = Object.keys(DFlow.DFlowQuoteSwapRequest.properties); - expect(props).toContain('approximateIfNoExactOut'); - }); - it('DFlowQuoteSwapResponse should include DFlow-specific fields', () => { const props = Object.keys(DFlow.DFlowQuoteSwapResponse.properties); expect(props).toContain('quoteResponse'); @@ -117,11 +30,5 @@ describe('DFlow Schema Tests', () => { }); }); - describe('Field Examples and Defaults', () => { - it('should have Solana mainnet-only network enum', () => { - const networkProp = DFlow.DFlowQuoteSwapRequest.properties.network; - expect(networkProp.default).toBe('mainnet-beta'); - expect(networkProp.enum).toEqual(['mainnet-beta']); - }); - }); + describe('Field Examples and Defaults', () => {}); }); diff --git a/test/connectors/evm-slippage.test.ts b/test/connectors/evm-slippage.test.ts new file mode 100644 index 0000000000..6919e8dab7 --- /dev/null +++ b/test/connectors/evm-slippage.test.ts @@ -0,0 +1,66 @@ +import fs from 'fs'; +import path from 'path'; + +import { slippageBasisPoints } from '../../src/connectors/evm-slippage'; + +describe('slippageBasisPoints', () => { + it('converts a percentage to the /10000 numerator the SDKs take', () => { + expect(slippageBasisPoints(1)).toBe(100); + expect(slippageBasisPoints(2)).toBe(200); + expect(slippageBasisPoints(5)).toBe(500); + }); + + it('handles a sub-percent tolerance', () => { + expect(slippageBasisPoints(0.5)).toBe(50); + }); + + it('rounds to whole basis points, which is what the denominator can express', () => { + expect(slippageBasisPoints(0.123)).toBe(12); + }); +}); + +// The defect was not the conversion, it was that there was no conversion: four CLMM +// liquidity routes wrote `new Percent(100, 10000)` and called it slippage, ignoring both +// the caller's slippagePct and the operator's configured one. An operator who widened +// slippagePct for a volatile pair still got 1%, and a revert that cost gas. +// +// This is a source check because that is the shape of the invariant — "no route builds a +// tolerance from a literal" is a statement about every route, including ones not written +// yet, and no unit test of a helper can make it. +describe('no connector builds a slippage tolerance from a literal', () => { + const connectorsDir = path.join(__dirname, '../../src/connectors'); + + const sourceFiles = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.isFile() && entry.name.endsWith('.ts') ? [full] : []; + }); + + it('derives every slippageTolerance from a slippagePct', () => { + const offenders: string[] = []; + for (const file of sourceFiles(connectorsDir)) { + const source = fs.readFileSync(file, 'utf8'); + for (const line of source.split('\n')) { + const assignment = line.match(/slippageTolerance\s*=\s*(.+)$/); + if (!assignment) continue; + // Any right-hand side that mentions a slippage value passes; uniswap's swap + // paths bind theirs to a local `slippage` first. What must not appear is a bare + // number, which is what all four liquidity routes had. + if (!/slippage/i.test(assignment[1])) { + offenders.push(`${path.relative(connectorsDir, file)}: ${line.trim()}`); + } + } + } + + expect(offenders).toEqual([]); + }); + + it('finds tolerances to check, so the check cannot pass vacuously', () => { + const withTolerance = sourceFiles(connectorsDir).filter((file) => + /slippageTolerance\s*=/.test(fs.readFileSync(file, 'utf8')), + ); + + expect(withTolerance.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/test/connectors/jupiter/jupiter.routes.test.ts b/test/connectors/jupiter/jupiter.routes.test.ts deleted file mode 100644 index 11be12a718..0000000000 --- a/test/connectors/jupiter/jupiter.routes.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('Jupiter Routes Structure', () => { - const CONNECTOR_NAME = 'jupiter'; - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should have appropriate route folders based on trading types', async () => { - // Get connector info - const response = await fastify.inject({ - method: 'GET', - url: '/config/connectors', - }); - - const { connectors } = JSON.parse(response.body); - const jupiterConfig = connectors.find((c: any) => c.name === CONNECTOR_NAME); - - expect(jupiterConfig).toBeDefined(); - - const connectorPath = path.join(__dirname, `../../../src/connectors/${CONNECTOR_NAME}`); - - // Check for router-routes if router is supported - if (jupiterConfig.trading_types.includes('router')) { - const routerRoutesPath = path.join(connectorPath, 'router-routes'); - expect(fs.existsSync(routerRoutesPath)).toBe(true); - - // Verify it has the standard router files - const files = fs.readdirSync(routerRoutesPath); - expect(files.some((f) => f.toLowerCase().includes('swap'))).toBe(true); - } - - // Ensure old 'routes' folder doesn't exist - const oldRoutesPath = path.join(connectorPath, 'routes'); - expect(fs.existsSync(oldRoutesPath)).toBe(false); - }); - }); - - describe('Route Registration', () => { - it('should register Jupiter router routes at /connectors/jupiter/router', async () => { - const routes = fastify.printRoutes(); - - // Check that Jupiter router routes are registered - expect(routes).toContain('jupiter/router/'); - expect(routes).toContain('quote-swap'); - expect(routes).toContain('execute-swap'); - }); - }); -}); diff --git a/test/connectors/jupiter/mocks/execute-swap.json b/test/connectors/jupiter/mocks/execute-swap.json deleted file mode 100644 index 734e5f3d9a..0000000000 --- a/test/connectors/jupiter/mocks/execute-swap.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "signature": "2opPG8d8XvnWg54FKutcx6gENWySPwbm1VMedp9PL8Krb7PoEFZv1X76HcJPAoo4gDNvmJrP3DBsVcQ1WTHE6VVo", - "status": 1, - "data": { - "tokenIn": "So11111111111111111111111111111111111111112", - "tokenOut": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "amountIn": 1.000011167, - "amountOut": 163.456119, - "fee": 0.000011167, - "baseTokenBalanceChange": -1.000011167, - "quoteTokenBalanceChange": 163.456119 - } -} diff --git a/test/connectors/jupiter/mocks/quote-response.json b/test/connectors/jupiter/mocks/quote-response.json deleted file mode 100644 index 893d546aa6..0000000000 --- a/test/connectors/jupiter/mocks/quote-response.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "inputMint": "So11111111111111111111111111111111111111112", - "inAmount": "1000000000", - "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "outAmount": "167000000", - "otherAmountThreshold": "166165000", - "swapMode": "ExactIn", - "slippageBps": 50, - "platformFee": null, - "priceImpactPct": "0.01", - "routePlan": [ - { - "swapInfo": { - "ammKey": "2QdhepnKRTLjjSqPL1PtKNwqrUkoLee5Gqs8bvZhRdMv", - "label": "Raydium CLMM", - "inputMint": "So11111111111111111111111111111111111111112", - "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "inAmount": "1000000000", - "outAmount": "167000000", - "feeAmount": "100000", - "feeMint": "So11111111111111111111111111111111111111112" - }, - "percent": 100 - } - ], - "contextSlot": 123456789, - "timeTaken": 0.123 -} \ No newline at end of file diff --git a/test/connectors/jupiter/mocks/quote-swap-buy.json b/test/connectors/jupiter/mocks/quote-swap-buy.json deleted file mode 100644 index b5f4ddc1ed..0000000000 --- a/test/connectors/jupiter/mocks/quote-swap-buy.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "quoteId": "jupiter-quote-buy-456", - "tokenIn": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "tokenOut": "So11111111111111111111111111111111111111112", - "amountIn": 1639.410442, - "amountOut": 10, - "price": 163.94104420000002, - "slippagePct": 1, - "priceWithSlippage": 165.58045464200002, - "minAmountOut": 9.9, - "maxAmountIn": 1655.804596, - "estimatedAmountIn": 10, - "estimatedAmountOut": 1639.410442, - "baseTokenBalanceChange": 10, - "quoteTokenBalanceChange": -1639.410442, - "computeUnits": 300000, - "poolAddress": "jupiter-aggregator", - "quoteResponse": { - "inputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "inAmount": "1639410442", - "outputMint": "So11111111111111111111111111111111111111112", - "outAmount": "10000000000", - "otherAmountThreshold": "9900000000", - "swapMode": "ExactOut", - "slippageBps": 100, - "priceImpactPct": "0.001", - "routePlan": [] - } -} diff --git a/test/connectors/jupiter/mocks/quote-swap-invalid-amount.json b/test/connectors/jupiter/mocks/quote-swap-invalid-amount.json deleted file mode 100644 index 32e3870070..0000000000 --- a/test/connectors/jupiter/mocks/quote-swap-invalid-amount.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "InternalServerError", - "message": "Failed to get Jupiter quote: Jupiter API error: Query parameter amount cannot be parsed: ParseIntError { kind: InvalidDigit }" -} \ No newline at end of file diff --git a/test/connectors/jupiter/mocks/quote-swap-invalid-token.json b/test/connectors/jupiter/mocks/quote-swap-invalid-token.json deleted file mode 100644 index 1d41951391..0000000000 --- a/test/connectors/jupiter/mocks/quote-swap-invalid-token.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 404, - "error": "NotFoundError", - "message": "Token not found: INVALID_TOKEN" -} \ No newline at end of file diff --git a/test/connectors/jupiter/mocks/quote-swap-sell.json b/test/connectors/jupiter/mocks/quote-swap-sell.json deleted file mode 100644 index 8776cd288b..0000000000 --- a/test/connectors/jupiter/mocks/quote-swap-sell.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "estimatedAmountIn": 0.1, - "estimatedAmountOut": 16.347067, - "minAmountOut": 16.347067, - "maxAmountIn": 0.1, - "baseTokenBalanceChange": -0.1, - "quoteTokenBalanceChange": 16.347067, - "price": 163.47066999999998, - "gasPrice": 0.5, - "gasLimit": 200000, - "gasCost": 0.000105, - "poolAddress": "jupiter-aggregator" -} \ No newline at end of file diff --git a/test/connectors/jupiter/mocks/quote-swap-slippage.json b/test/connectors/jupiter/mocks/quote-swap-slippage.json deleted file mode 100644 index 740ec2c21b..0000000000 --- a/test/connectors/jupiter/mocks/quote-swap-slippage.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "quoteId": "jupiter-quote-slippage-789", - "tokenIn": "So11111111111111111111111111111111111111112", - "tokenOut": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "amountIn": 0.1, - "amountOut": 16.391966, - "price": 163.91966, - "slippagePct": 2.5, - "priceWithSlippage": 159.821618, - "minAmountOut": 15.98216585, - "maxAmountIn": 0.1, - "estimatedAmountIn": 0.1, - "estimatedAmountOut": 16.391966, - "baseTokenBalanceChange": -0.1, - "quoteTokenBalanceChange": 16.391966, - "computeUnits": 300000, - "poolAddress": "jupiter-aggregator", - "quoteResponse": { - "inputMint": "So11111111111111111111111111111111111111112", - "inAmount": "100000000", - "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "outAmount": "16391966", - "otherAmountThreshold": "15982166", - "swapMode": "ExactIn", - "slippageBps": 250, - "priceImpactPct": "0.001", - "routePlan": [] - } -} diff --git a/test/connectors/jupiter/mocks/quote-swap.json b/test/connectors/jupiter/mocks/quote-swap.json deleted file mode 100644 index 4dca983f5e..0000000000 --- a/test/connectors/jupiter/mocks/quote-swap.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "quoteId": "jupiter-quote-123", - "tokenIn": "So11111111111111111111111111111111111111112", - "tokenOut": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "amountIn": 1, - "amountOut": 163.456119, - "price": 163.456119, - "slippagePct": 1, - "priceWithSlippage": 161.821558, - "minAmountOut": 161.821558, - "maxAmountIn": 1, - "estimatedAmountIn": 1, - "estimatedAmountOut": 163.456119, - "baseTokenBalanceChange": -1, - "quoteTokenBalanceChange": 163.456119, - "computeUnits": 300000, - "poolAddress": "jupiter-aggregator", - "quoteResponse": { - "inputMint": "So11111111111111111111111111111111111111112", - "inAmount": "1000000000", - "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "outAmount": "163456119", - "otherAmountThreshold": "161821558", - "swapMode": "ExactIn", - "slippageBps": 100, - "priceImpactPct": "0.001", - "routePlan": [] - } -} diff --git a/test/connectors/jupiter/mocks/swap-response.json b/test/connectors/jupiter/mocks/swap-response.json deleted file mode 100644 index b0ea10a9a4..0000000000 --- a/test/connectors/jupiter/mocks/swap-response.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "swapTransaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHEjNWHrJ7FKVwHiCrmDV3qPvjfH2r+GqpmE/rTjfuXsUst2amuVJqKGbsvlGLbfrfntZ0Vw0hIZ5y6nHU8j+iKgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+Fm0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGp9UXGSxcUSGMyUw9SvF/WNruCJuh/UTj29mKAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkBBgEJAwoCBAcFAAgLCQoLBwwNDhARAA8MExQSFhcVGBkBGhscBB0BHhkBCxICAAEJA5Cq1AAAAAAAMgAAAAEAAAAAAAAAAAAAAABbksAc6IACAAAAAADIAQAAAAAAAA==", - "lastValidBlockHeight": 297712058, - "prioritizationFeeLamports": 125000 -} \ No newline at end of file diff --git a/test/connectors/jupiter/router-routes/quoteSwap.test.ts b/test/connectors/jupiter/router-routes/quoteSwap.test.ts index 8ad9cc17d8..92f1b8d4e4 100644 --- a/test/connectors/jupiter/router-routes/quoteSwap.test.ts +++ b/test/connectors/jupiter/router-routes/quoteSwap.test.ts @@ -1,6 +1,7 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Jupiter } from '../../../../src/connectors/jupiter/jupiter'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/jupiter/jupiter'); @@ -9,7 +10,7 @@ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); try { - const { quoteSwapRoute } = await import('../../../../src/connectors/jupiter/router-routes/quoteSwap'); + const { quoteSwapRoute } = await import('../../../../src/trading/trading-router-routes/quoteSwap'); await server.register(quoteSwapRoute); } catch (error) { console.error('Failed to import route:', error); @@ -75,7 +76,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -85,17 +87,19 @@ describe('GET /quote-swap', () => { }); if (response.statusCode !== 200) { - console.log('Response error:', JSON.parse(response.body)); + console.log('Response error:', parseWire(response.body)); } expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId'); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut', 15); expect(body).toHaveProperty('minAmountOut'); expect(body).toHaveProperty('maxAmountIn'); expect(body).toHaveProperty('price', 150); - expect(body.quoteResponse).toHaveProperty('priceImpactPct', '0.001'); + // The connector's raw provider payload (quoteResponse / routerResult) is not part + // of the unified router response schema, which serializes the shared quote fields + // plus quoteId. Assertions on it moved out with the per-connector route. expect(body).toHaveProperty('tokenIn', mockSOL.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); }); @@ -121,7 +125,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -131,14 +136,13 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId'); - expect(body).toHaveProperty('amountIn', 15); + expect(Number(body.amountIn)).toBe(15); expect(body).toHaveProperty('amountOut', 0.1); expect(body).toHaveProperty('minAmountOut'); expect(body).toHaveProperty('maxAmountIn'); expect(body).toHaveProperty('price', 150); - expect(body.quoteResponse).toHaveProperty('priceImpactPct', '0.001'); expect(body).toHaveProperty('tokenIn', mockUSDC.address); expect(body).toHaveProperty('tokenOut', mockSOL.address); }); @@ -153,7 +157,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', baseToken: 'INVALID', quoteToken: 'USDC', amount: '0.1', @@ -162,7 +167,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should return 400 if no routes found', async () => { @@ -180,7 +185,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -190,7 +196,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should approximate BUY via sell leg when ExactOut is not supported', async () => { @@ -227,7 +233,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -237,9 +244,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('approximation', true); - expect(body).toHaveProperty('amountIn', 15); + expect(Number(body.amountIn)).toBe(15); expect(body.amountOut).toBeCloseTo(0.0999); // Input is fixed for the approximated ExactIn quote expect(body.maxAmountIn).toBeCloseTo(15); @@ -264,7 +271,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -275,7 +283,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.message).toContain('ExactOut'); expect(mockJupiterInstance.getQuote).toHaveBeenCalledTimes(1); }); @@ -295,7 +303,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -305,7 +314,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('error'); expect(body.message).toContain('No route found for'); expect(body.message).toContain('SOL'); diff --git a/test/connectors/jupiter/schemas.test.ts b/test/connectors/jupiter/schemas.test.ts index 396d9d24e4..37f099667a 100644 --- a/test/connectors/jupiter/schemas.test.ts +++ b/test/connectors/jupiter/schemas.test.ts @@ -6,38 +6,6 @@ import * as Base from '../../../src/schemas/router-schema'; describe('Jupiter Schema Tests', () => { describe('Schema Superset Validation', () => { - it('JupiterQuoteSwapRequest should be a superset of QuoteSwapRequest', () => { - // Get all required fields from base schema - const baseRequired = Base.QuoteSwapRequest.required || []; - const jupiterRequired = Jupiter.JupiterQuoteSwapRequest.required || []; - - // Check that all base required fields are in Jupiter schema - for (const field of baseRequired) { - expect(jupiterRequired).toContain(field); - } - - // Check that all base properties exist in Jupiter schema - const baseProps = Object.keys(Base.QuoteSwapRequest.properties); - const jupiterProps = Object.keys(Jupiter.JupiterQuoteSwapRequest.properties); - - for (const prop of baseProps) { - expect(jupiterProps).toContain(prop); - } - - // Verify a sample base request is valid for Jupiter schema - const sampleRequest = { - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.QuoteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(Jupiter.JupiterQuoteSwapRequest, sampleRequest)).toBe(true); - }); - it('JupiterQuoteSwapResponse should be a superset of QuoteSwapResponse', () => { // Get all required fields from base schema const baseRequired = Base.QuoteSwapResponse.required || []; @@ -56,108 +24,17 @@ describe('Jupiter Schema Tests', () => { expect(jupiterProps).toContain(prop); } }); - - it('JupiterExecuteQuoteRequest should be a superset of ExecuteQuoteRequest', () => { - // Get all required fields from base schema - const baseRequired = Base.ExecuteQuoteRequest.required || []; - const jupiterRequired = Jupiter.JupiterExecuteQuoteRequest.required || []; - - // Check that all base required fields are in Jupiter schema - for (const field of baseRequired) { - expect(jupiterRequired).toContain(field); - } - - // Check that all base properties exist in Jupiter schema - const baseProps = Object.keys(Base.ExecuteQuoteRequest.properties); - const jupiterProps = Object.keys(Jupiter.JupiterExecuteQuoteRequest.properties); - - for (const prop of baseProps) { - expect(jupiterProps).toContain(prop); - } - - // Verify a sample base request is valid for Jupiter schema - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - quoteId: '123e4567-e89b-12d3-a456-426614174000', - }; - - expect(Value.Check(Base.ExecuteQuoteRequest, sampleRequest)).toBe(true); - expect(Value.Check(Jupiter.JupiterExecuteQuoteRequest, sampleRequest)).toBe(true); - }); - - it('JupiterExecuteSwapRequest should be a superset of ExecuteSwapRequest', () => { - // Get all required fields from base schema - const baseRequired = Base.ExecuteSwapRequest.required || []; - const jupiterRequired = Jupiter.JupiterExecuteSwapRequest.required || []; - - // Check that all base required fields are in Jupiter schema - for (const field of baseRequired) { - expect(jupiterRequired).toContain(field); - } - - // Check that all base properties exist in Jupiter schema - const baseProps = Object.keys(Base.ExecuteSwapRequest.properties); - const jupiterProps = Object.keys(Jupiter.JupiterExecuteSwapRequest.properties); - - for (const prop of baseProps) { - expect(jupiterProps).toContain(prop); - } - - // Verify a sample base request is valid for Jupiter schema - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.ExecuteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(Jupiter.JupiterExecuteSwapRequest, sampleRequest)).toBe(true); - }); }); describe('Jupiter-specific Fields', () => { - it('JupiterQuoteSwapRequest should include Jupiter-specific fields', () => { - const props = Object.keys(Jupiter.JupiterQuoteSwapRequest.properties); - expect(props).toContain('restrictIntermediateTokens'); - expect(props).toContain('onlyDirectRoutes'); - }); - - it('JupiterExecuteQuoteRequest should include Jupiter-specific fields', () => { - const props = Object.keys(Jupiter.JupiterExecuteQuoteRequest.properties); - expect(props).toContain('priorityLevel'); - expect(props).toContain('maxLamports'); - }); - - it('JupiterExecuteSwapRequest should include Jupiter-specific fields', () => { - const props = Object.keys(Jupiter.JupiterExecuteSwapRequest.properties); - expect(props).toContain('restrictIntermediateTokens'); - expect(props).toContain('onlyDirectRoutes'); - expect(props).toContain('priorityLevel'); - expect(props).toContain('maxLamports'); - }); - + // Routing policy (restrictIntermediateTokens/onlyDirectRoutes) and priority fees + // (priorityLevel/maxLamports) are connector-config settings, not request params. + // The one request-level knob shared by every Solana router is approximateIfNoExactOut. it('JupiterQuoteSwapResponse should include Jupiter-specific fields', () => { const props = Object.keys(Jupiter.JupiterQuoteSwapResponse.properties); expect(props).toContain('quoteResponse'); }); }); - describe('Field Examples and Defaults', () => { - it('should have Solana-specific examples in fields', () => { - const networkProp = Jupiter.JupiterQuoteSwapRequest.properties.network; - expect(networkProp.default).toBe('mainnet-beta'); - expect(networkProp.enum).toContain('mainnet-beta'); - - const baseTokenProp = Jupiter.JupiterQuoteSwapRequest.properties.baseToken; - expect(baseTokenProp.examples).toContain('SOL'); - - const quoteTokenProp = Jupiter.JupiterQuoteSwapRequest.properties.quoteToken; - expect(quoteTokenProp.examples).toContain('USDC'); - }); - }); + describe('Field Examples and Defaults', () => {}); }); diff --git a/test/connectors/jupiter/swap.test.js b/test/connectors/jupiter/swap.test.js deleted file mode 100644 index e6fe869abe..0000000000 --- a/test/connectors/jupiter/swap.test.js +++ /dev/null @@ -1,437 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'jupiter'; -const NETWORK = 'mainnet-beta'; // Only test mainnet-beta -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate swap quote response structure based on QuoteSwapResponse schema -function validateSwapQuote(response) { - return ( - response && - typeof response.quoteId === 'string' && - typeof response.tokenIn === 'string' && - typeof response.tokenOut === 'string' && - typeof response.amountIn === 'number' && - typeof response.amountOut === 'number' && - typeof response.price === 'number' && - typeof response.slippagePct === 'number' && - typeof response.priceWithSlippage === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - response.quoteResponse && // Jupiter-specific nested object - typeof response.quoteResponse.inputMint === 'string' && - typeof response.quoteResponse.inAmount === 'string' && - typeof response.quoteResponse.outputMint === 'string' && - typeof response.quoteResponse.outAmount === 'string' - ); -} - -// Function to validate swap execution response structure based on ExecuteSwapResponse schema -function validateSwapExecution(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && // Added: status field - (response.status !== 1 || // If not CONFIRMED - (response.data && // then data is optional - typeof response.data.tokenIn === 'string' && - typeof response.data.tokenOut === 'string' && - typeof response.data.amountIn === 'number' && - typeof response.data.amountOut === 'number' && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenBalanceChange === 'number' && - typeof response.data.quoteTokenBalanceChange === 'number')) - ); -} - -// Tests -describe('Jupiter Swap Tests (Solana Mainnet)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('quote-swap'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.estimatedAmountIn).toBe(1.0); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Load BUY mock response - const mockBuyResponse = loadMockResponse('quote-swap-buy'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 10, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values for BUY - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - expect(response.data.estimatedAmountIn).toBe(10); // Input is USDC amount for BUY - }); - - test('handles error with invalid token', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Token not found: INVALID', - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/quote-swap`, { - params: { - network: NETWORK, - baseToken: 'INVALID', - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Token not found: INVALID', - }, - }, - }); - }); - - test('handles missing required parameters', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Missing required parameter: amount', - }, - }, - }); - - // Make the request without amount - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - // Missing amount - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - }, - }, - }); - }); - - test('returns quote with custom slippage', async () => { - // Load mock response for custom slippage - const mockResponse = loadMockResponse('quote-swap-slippage'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request with custom slippage - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 0.1, - slippagePct: 2.5, // 2.5% slippage - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // With higher slippage, minAmountOut should be lower than the estimated output - expect(response.data.minAmountOut).toBeLessThan(response.data.estimatedAmountOut); - expect(response.data.minAmountOut).toBeCloseTo(15.98, 1); // ~2.5% less than 16.39 - - // Verify axios was called with slippage parameter - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - slippagePct: 2.5, - }), - }), - ); - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Load mock responses - const quoteResponse = loadMockResponse('quote-swap'); - const executeResponse = { - signature: '2XGwPTNGFvRjLb6HkBQq8qwsRZ8XNjEjvuehVeNDdz3TxxKnvYBfgMsYCQKNHMpDYzKcUfKdCwzBvkPvDz5aLfYc', - status: 1, // CONFIRMED - data: { - tokenIn: BASE_TOKEN, - tokenOut: QUOTE_TOKEN, - amountIn: 1.0, - amountOut: 163.456119, - fee: 0.001, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 163.456119, - }, - }; - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapExecution(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.signature).toBeDefined(); - expect(response.data.signature.length).toBeGreaterThan(30); // Solana signatures are long - expect(response.data.status).toBe(1); // CONFIRMED - expect(response.data.data.amountIn).toBeCloseTo( - quoteResponse.estimatedAmountIn, - 3, // Allow some difference due to fees - ); - expect(response.data.data.amountOut).toBeCloseTo(quoteResponse.estimatedAmountOut, 3); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, - expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }), - ); - }); - - test('returns successful swap execution with fee parameters', async () => { - // Mock response with status-based format - const executeResponse = { - signature: '3YHqPTNGFvRjLb6HkBQq8qwsRZ8XNjEjvuehVeNDdz3TxxKnvYBfgMsYCQKNHMpDYzKcUfKdCwzBvkPvDz5aLfYd', - status: 1, // CONFIRMED - data: { - tokenIn: BASE_TOKEN, - tokenOut: QUOTE_TOKEN, - amountIn: 1.0, - amountOut: 16.391234, - fee: 0.002, // Higher fee due to priority - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 16.391234, - }, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request with fee parameters - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - priorityLevel: 'veryHigh', - maxLamports: 1000000, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapExecution(response.data)).toBe(true); - expect(response.data.status).toBe(1); // CONFIRMED - expect(response.data.data.fee).toBe(0.002); // Higher fee - - // Verify axios was called with fee parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, - expect.objectContaining({ - priorityLevel: 'veryHigh', - maxLamports: 1000000, - }), - ); - }); - - test('returns pending swap execution', async () => { - // Mock response with PENDING status - const executeResponse = { - signature: '4ZIrQTNGFvRjLb6HkBQq8qwsRZ8XNjEjvuehVeNDdz3TxxKnvYBfgMsYCQKNHMpDYzKcUfKdCwzBvkPvDz5aLfYe', - status: 0, // PENDING - // No data field when pending - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapExecution(response.data)).toBe(true); - expect(response.data.signature).toBeDefined(); - expect(response.data.status).toBe(0); // PENDING - expect(response.data.data).toBeUndefined(); // No data when pending - }); - - test('handles execution errors', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 500, - data: { - error: 'Transaction simulation failed', - code: 500, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1000000.0, // Very large amount to cause error - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 500, - data: { - error: 'Transaction simulation failed', - }, - }, - }); - }); - }); -}); diff --git a/test/connectors/meteora/amm-routes/addLiquidity-delegation.test.ts b/test/connectors/meteora/amm-routes/addLiquidity-delegation.test.ts new file mode 100644 index 0000000000..3f8405e41a --- /dev/null +++ b/test/connectors/meteora/amm-routes/addLiquidity-delegation.test.ts @@ -0,0 +1,59 @@ +import { openPosition } from '../../../../src/connectors/meteora/amm-routes/openPosition'; + +// Opening a DAMM v2 position is its own on-chain call (it mints the position NFT and +// locks rent), so it lives in openPosition. addLiquidity without a position address +// still opens one rather than failing or picking an existing position silently — it +// delegates, and passes the position's address and rent through, so the caller who +// just paid to open it is told which position it is (GW-6). + +jest.mock('../../../../src/connectors/meteora/amm-routes/openPosition', () => ({ + openPosition: jest.fn(), +})); + +const WALLET = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; +const POOL = 'FAKEpoolAddress1111111111111111111111111111'; + +describe('meteora AMM addLiquidity — new-position delegation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to openPosition when no position address is given', async () => { + (openPosition as jest.Mock).mockResolvedValue({ + signature: 'sig-open', + status: 1, + data: { + fee: 0.00001, + positionAddress: 'FAKEpositionAddress11111111111111111111111', + positionRent: 0.0575, + baseTokenAmountAdded: 0.1, + quoteTokenAmountAdded: 20, + }, + }); + + const { addLiquidity } = await import('../../../../src/connectors/meteora/amm-routes/addLiquidity'); + const result = await addLiquidity('mainnet-beta', WALLET, POOL, 0.1, 20, 1); + + expect(openPosition).toHaveBeenCalledWith('mainnet-beta', WALLET, POOL, 0.1, 20, 1); + expect(result).toEqual({ + signature: 'sig-open', + status: 1, + data: { + fee: 0.00001, + positionAddress: 'FAKEpositionAddress11111111111111111111111', + positionRent: 0.0575, + baseTokenAmountAdded: 0.1, + quoteTokenAmountAdded: 20, + }, + }); + }); + + it('passes a pending open through without inventing data', async () => { + (openPosition as jest.Mock).mockResolvedValue({ signature: 'sig-pending', status: 0 }); + + const { addLiquidity } = await import('../../../../src/connectors/meteora/amm-routes/addLiquidity'); + const result = await addLiquidity('mainnet-beta', WALLET, POOL, 0.1, 20, 1); + + expect(result).toEqual({ signature: 'sig-pending', status: 0 }); + }); +}); diff --git a/test/connectors/meteora/amm-routes/closePosition.test.ts b/test/connectors/meteora/amm-routes/closePosition.test.ts new file mode 100644 index 0000000000..8f124452e2 --- /dev/null +++ b/test/connectors/meteora/amm-routes/closePosition.test.ts @@ -0,0 +1,175 @@ +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/meteora/meteora-damm'); +jest.mock('../../../../src/connectors/meteora/meteora.config', () => ({ + MeteoraConfig: { config: { slippagePct: 1 } }, +})); + +const WALLET = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; +const POOL = new PublicKey('11111111111111111111111111111112'); +const POSITION = new PublicKey('SysvarRent111111111111111111111111111111111'); +const POSITION_NFT_ACCOUNT = new PublicKey('SysvarC1ock11111111111111111111111111111111'); +const USDC = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); +const WSOL = new PublicKey('So11111111111111111111111111111111111111112'); + +// The three rent-bearing accounts a DAMM v2 close closes, at the lamports mainnet +// charged for them on 67ZzMAHv… — the close that found this. The position is the +// smallest of the three, which is why reading it alone captured 38% of the refund. +const POSITION_RENT = 3_730_560; +const NFT_MINT_RENT = 4_127_280; +const NFT_ACCOUNT_RENT = 2_039_280; +const RENT_LAMPORTS = POSITION_RENT + NFT_MINT_RENT + NFT_ACCOUNT_RENT; // 0.00990912 SOL +const TX_FEE_LAMPORTS = 10_000; +// A wrapped-SOL account of the wallet's own, closed by the same transaction. Its rent +// comes back like any other, but the WSOL it already held is a balance the wallet had +// before the close and is not this position's liquidity either. +const WSOL_ACCOUNT_RENT = 2_039_280; +const WSOL_HELD_BEFORE = 8_374_531; + +const poolState = { + tokenAMint: USDC, + tokenBMint: WSOL, + sqrtMinPrice: new BN(1), + sqrtMaxPrice: new BN(1000), + sqrtPrice: new BN(100), + collectFeeMode: 0, + tokenAAmount: new BN(1000), + tokenBAmount: new BN(1000), + liquidity: new BN(1000), +}; + +const removeAllLiquidityAndClosePosition = jest.fn(); + +const setup = ({ vested = new BN(0) }: { vested?: BN } = {}) => { + (MeteoraDamm.getInstance as jest.Mock).mockResolvedValue({ + getPoolState: jest.fn().mockResolvedValue(poolState), + getUserPositions: jest.fn().mockResolvedValue([ + { + position: POSITION, + positionNftAccount: POSITION_NFT_ACCOUNT, + positionState: { unlockedLiquidity: new BN(500), vestedLiquidity: vested }, + }, + ]), + getCurrentPoint: jest.fn().mockReturnValue(new BN(1)), + cpAmm: { + getWithdrawQuote: jest.fn().mockReturnValue({ outAmountA: new BN(100), outAmountB: new BN(100) }), + getAllVestingsByPosition: jest.fn().mockResolvedValue([]), + removeAllLiquidityAndClosePosition, + }, + }); + + (Solana.getInstance as jest.Mock).mockResolvedValue({ + connection: { + getSlot: jest.fn().mockResolvedValue(1), + getBlockTime: jest.fn().mockResolvedValue(1_700_000_000), + }, + sendAndConfirmTransactionForWallet: jest.fn().mockResolvedValue({ signature: 'sig-close' }), + getConfirmedTransactionData: jest.fn().mockResolvedValue({ + meta: { + fee: TX_FEE_LAMPORTS, + // wallet, position, NFT mint, the NFT's token account, and the wallet's WSOL + // account — the last four all close here and all four refund to the first. + // A wrapped-SOL account's lamports are its rent plus what it holds wrapped. + preBalances: [ + 1_000_000_000, + POSITION_RENT, + NFT_MINT_RENT, + NFT_ACCOUNT_RENT, + WSOL_ACCOUNT_RENT + WSOL_HELD_BEFORE, + ], + postBalances: [ + 1_000_000_000 + RENT_LAMPORTS + WSOL_ACCOUNT_RENT + WSOL_HELD_BEFORE - TX_FEE_LAMPORTS, + 0, + 0, + 0, + 0, + ], + preTokenBalances: [ + { accountIndex: 3, mint: POSITION.toBase58(), uiTokenAmount: { amount: '1' } }, + { accountIndex: 4, mint: WSOL.toBase58(), uiTokenAmount: { amount: String(WSOL_HELD_BEFORE) } }, + ], + postTokenBalances: [], + }, + }), + // Base is USDC (25 out), quote is wrapped SOL (0.5 out). The SOL figure also + // carries the rent refund, which the connector backs out. It does NOT carry the + // transaction fee: extractBalanceChangesAndFee adds that back for the fee payer + // and reports it separately, so this fixture encodes that contract — if it ever + // changes, this test fails rather than the amounts silently drifting by a fee. + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ + balanceChanges: [25, 0.5 + (RENT_LAMPORTS + WSOL_ACCOUNT_RENT + WSOL_HELD_BEFORE) / 1e9], + }), + }); +}; + +describe('meteora AMM closePosition', () => { + beforeEach(() => { + jest.clearAllMocks(); + removeAllLiquidityAndClosePosition.mockResolvedValue({}); + }); + + it('reports the rent every closed account refunds, not the position account alone', async () => { + setup(); + const { closePosition } = await import('../../../../src/connectors/meteora/amm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POOL.toBase58(), POSITION.toBase58(), 1); + + expect(result.status).toBe(1); + // Position + NFT mint + NFT account + the WSOL account's own rent. Reading the + // position alone reported 0.00373056 of a 0.01194840 refund. + expect(result.data?.positionRentRefunded).toBeCloseTo((RENT_LAMPORTS + WSOL_ACCOUNT_RENT) / 1e9, 9); + expect(result.data?.positionRentRefunded).not.toBeCloseTo(POSITION_RENT / 1e9, 9); + expect(result.data?.fee).toBeCloseTo(TX_FEE_LAMPORTS / 1e9); + }); + + it('closes the account rather than just withdrawing — which is what earns the refund', async () => { + setup(); + const { closePosition } = await import('../../../../src/connectors/meteora/amm-routes/closePosition'); + + await closePosition('mainnet-beta', WALLET, POOL.toBase58(), POSITION.toBase58(), 1); + + expect(removeAllLiquidityAndClosePosition).toHaveBeenCalledWith( + expect.objectContaining({ position: POSITION, positionNftAccount: POSITION_NFT_ACCOUNT }), + ); + }); + + it('backs every closed account out of a native-token side', async () => { + setup(); + const { closePosition } = await import('../../../../src/connectors/meteora/amm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POOL.toBase58(), POSITION.toBase58(), 1); + + // The wallet's WSOL-side change was liquidity + four accounts' rent + the WSOL it + // was already holding. Only the liquidity is reported: the rest is not this + // position's money, and leaving any of it in inflated the recorded withdrawal 4x. + expect(result.data?.quoteTokenAmountRemoved).toBeCloseTo(0.5, 9); + // The non-native side needs no adjustment. + expect(result.data?.baseTokenAmountRemoved).toBeCloseTo(25); + }); + + it('refuses to close a position that still holds vested liquidity', async () => { + setup({ vested: new BN(100) }); + const { closePosition } = await import('../../../../src/connectors/meteora/amm-routes/closePosition'); + + await expect(closePosition('mainnet-beta', WALLET, POOL.toBase58(), POSITION.toBase58(), 1)).rejects.toThrow( + /vested \(locked\) liquidity/, + ); + expect(removeAllLiquidityAndClosePosition).not.toHaveBeenCalled(); + }); + + it('reports pending without rent data when the transaction has not confirmed', async () => { + setup(); + const solana = await (Solana.getInstance as jest.Mock)('mainnet-beta'); + solana.getConfirmedTransactionData.mockResolvedValue(null); + + const { closePosition } = await import('../../../../src/connectors/meteora/amm-routes/closePosition'); + const result = await closePosition('mainnet-beta', WALLET, POOL.toBase58(), POSITION.toBase58(), 1); + + expect(result).toEqual({ signature: 'sig-close', status: 0 }); + }); +}); diff --git a/test/connectors/meteora/amm-routes/create-pool.test.ts b/test/connectors/meteora/amm-routes/create-pool.test.ts index 39c4fff3c0..5560c4c149 100644 --- a/test/connectors/meteora/amm-routes/create-pool.test.ts +++ b/test/connectors/meteora/amm-routes/create-pool.test.ts @@ -6,7 +6,7 @@ jest.mock('../../../../src/connectors/meteora/meteora-damm'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/meteora/amm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-amm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -33,7 +33,8 @@ describe('POST /create-pool (Meteora DAMM v2)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: '82Sg8kkChhY7Qb2ptR4uLGqLg7Zm3z9v9tQ6Zb6Jk4iZ', baseToken: 'SOL', quoteToken: 'USDC', diff --git a/test/connectors/meteora/amm-routes/poolInfo.test.ts b/test/connectors/meteora/amm-routes/poolInfo.test.ts index 0d8be5e891..01536e2032 100644 --- a/test/connectors/meteora/amm-routes/poolInfo.test.ts +++ b/test/connectors/meteora/amm-routes/poolInfo.test.ts @@ -1,5 +1,6 @@ import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/connectors/meteora/meteora-damm'); @@ -10,7 +11,7 @@ const mockUSDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { poolInfoRoute } = await import('../../../../src/connectors/meteora/amm-routes/poolInfo'); + const { poolInfoRoute } = await import('../../../../src/trading/trading-amm-routes/pool-info'); await server.register(poolInfoRoute); return server; }; @@ -46,11 +47,11 @@ describe('GET /pool-info (Meteora DAMM v2)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'meteora', poolAddress: mockPoolAddress }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toEqual({ address: mockPoolAddress, baseTokenAddress: mockSOL, @@ -70,7 +71,7 @@ describe('GET /pool-info (Meteora DAMM v2)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'meteora', poolAddress: mockPoolAddress }, }); expect(response.statusCode).toBe(404); diff --git a/test/connectors/meteora/amm-routes/position-rent.test.ts b/test/connectors/meteora/amm-routes/position-rent.test.ts new file mode 100644 index 0000000000..106b57a1bc --- /dev/null +++ b/test/connectors/meteora/amm-routes/position-rent.test.ts @@ -0,0 +1,46 @@ +import { NATIVE_MINT } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; + +import { liquidityWithoutRent } from '../../../../src/chains/solana/solana.utils'; + +const OTHER_MINT = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); // USDC + +// Both routes back the accounts' lamports out of a native-token side, for the same reason +// and in the same direction: at open the wallet's SOL change carries what the position's +// accounts locked, at close it carries what they gave back. Neither figure is liquidity. +// One helper serves both, so these run against the code the routes call rather than a +// restatement of it. What goes in comes from accountLifecycleSol — see +// test/chains/solana/account-lifecycle.test.ts. +describe('liquidityWithoutRent', () => { + const RENT = 0.0099; + + it('leaves the liquidity when the native change carries the rent', () => { + // The live case that found this: 0.0152 left the wallet, 0.0053 of it was liquidity. + expect(liquidityWithoutRent(-0.0152083, NATIVE_MINT, RENT)).toBeCloseTo(0.0053083, 7); + }); + + it('leaves a non-native side untouched, since no rent rode on it', () => { + expect(liquidityWithoutRent(-3000, OTHER_MINT, RENT)).toBe(3000); + }); + + it('is direction-agnostic: the sign comes off before the rent comes out', () => { + expect(liquidityWithoutRent(0.0152083, NATIVE_MINT, RENT)).toBeCloseTo(0.0053083, 7); + }); + + it('clamps rather than reporting a negative deposit', () => { + // A native change smaller than the rent means the rent dominated the transaction. + // Zero is the truthful reading; a negative would be arithmetic leaking into a field + // that means a quantity of tokens. + expect(liquidityWithoutRent(-0.001, NATIVE_MINT, RENT)).toBe(0); + }); + + it('is a no-op when no rent moved', () => { + expect(liquidityWithoutRent(-0.0152083, NATIVE_MINT, 0)).toBeCloseTo(0.0152083, 7); + }); + + it('would clamp every native side to zero if handed lamports', () => { + // Why accountLifecycleSol returns SOL and says so. The units have to agree; if they + // do not, this is the shape of the failure — silent, and total. + expect(liquidityWithoutRent(-0.0152083, NATIVE_MINT, RENT * 1e9)).toBe(0); + }); +}); diff --git a/test/connectors/meteora/amm-routes/quote-liquidity.test.ts b/test/connectors/meteora/amm-routes/quote-liquidity.test.ts index 640a56b0b2..ecfd2fcb1e 100644 --- a/test/connectors/meteora/amm-routes/quote-liquidity.test.ts +++ b/test/connectors/meteora/amm-routes/quote-liquidity.test.ts @@ -2,6 +2,7 @@ import BN from 'bn.js'; import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/connectors/meteora/meteora-damm'); @@ -10,7 +11,7 @@ const mockPoolAddress = 'FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteLiquidityRoute } = await import('../../../../src/connectors/meteora/amm-routes/quoteLiquidity'); + const { quoteLiquidityRoute } = await import('../../../../src/trading/trading-amm-routes/quote-liquidity'); await server.register(quoteLiquidityRoute); return server; }; @@ -68,7 +69,8 @@ describe('GET /quote-liquidity (Meteora DAMM v2)', () => { method: 'GET', url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', poolAddress: mockPoolAddress, baseTokenAmount: '0.01', quoteTokenAmount: '2', @@ -77,7 +79,7 @@ describe('GET /quote-liquidity (Meteora DAMM v2)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toMatchObject({ baseLimited: true, baseTokenAmount: 0.01, diff --git a/test/connectors/meteora/amm-routes/quote-swap.test.ts b/test/connectors/meteora/amm-routes/quote-swap.test.ts index 8fa4385496..fe9477db0b 100644 --- a/test/connectors/meteora/amm-routes/quote-swap.test.ts +++ b/test/connectors/meteora/amm-routes/quote-swap.test.ts @@ -3,6 +3,7 @@ import BN from 'bn.js'; import { MeteoraDamm } from '../../../../src/connectors/meteora/meteora-damm'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/connectors/meteora/meteora-damm'); @@ -13,8 +14,8 @@ const mockUSDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/meteora/amm-routes/quoteSwap'); - await server.register(quoteSwapRoute); + const { makeQuoteSwapRoute } = await import('../../../../src/trading/pool-swap-routes'); + await server.register(makeQuoteSwapRoute('amm')); return server; }; @@ -68,7 +69,8 @@ describe('GET /quote-swap (Meteora DAMM v2)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', poolAddress: mockPoolAddress, baseToken: 'SOL', amount: '0.1', @@ -78,7 +80,7 @@ describe('GET /quote-swap (Meteora DAMM v2)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(getQuote2).toHaveBeenCalledWith(expect.objectContaining({ swapMode: 0 })); // ExactIn expect(body).toMatchObject({ poolAddress: mockPoolAddress, @@ -107,7 +109,8 @@ describe('GET /quote-swap (Meteora DAMM v2)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', poolAddress: mockPoolAddress, baseToken: 'SOL', amount: '0.1', @@ -117,7 +120,7 @@ describe('GET /quote-swap (Meteora DAMM v2)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(getQuote2).toHaveBeenCalledWith(expect.objectContaining({ swapMode: 2 })); // ExactOut expect(body).toMatchObject({ poolAddress: mockPoolAddress, @@ -141,7 +144,8 @@ describe('GET /quote-swap (Meteora DAMM v2)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', poolAddress: mockPoolAddress, baseToken: 'Es9vMFrzaCERmJfrF4H2FYD4KCon15JpFuLYc7uGZa9K', // USDT, not in pool amount: '0.1', diff --git a/test/connectors/meteora/amm-routes/removeLiquidity-delegation.test.ts b/test/connectors/meteora/amm-routes/removeLiquidity-delegation.test.ts new file mode 100644 index 0000000000..3f26e344d2 --- /dev/null +++ b/test/connectors/meteora/amm-routes/removeLiquidity-delegation.test.ts @@ -0,0 +1,73 @@ +import { closePosition } from '../../../../src/connectors/meteora/amm-routes/closePosition'; + +// Closing a DAMM v2 position is its own on-chain call: it withdraws the liquidity AND +// closes the position account, which is what returns the rent. Withdrawing all the +// liquidity without closing leaves an empty position NFT holding that rent, and nothing +// later reclaims it — on a small position the rent is more than the liquidity. +// +// So removeLiquidity at 100% delegates to closePosition rather than doing its own +// withdrawal. A partial removal must not, because the position stays open and there is +// no rent to return. + +jest.mock('../../../../src/connectors/meteora/amm-routes/closePosition', () => ({ + closePosition: jest.fn(), +})); + +const WALLET = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; +const POOL = 'FAKEpoolAddress1111111111111111111111111111'; +const POSITION = 'FAKEpositionAddress11111111111111111111111'; + +const CLOSED = { + signature: 'sig-close', + status: 1, + data: { + fee: 0.00001, + positionRentRefunded: 0.0099, + baseTokenAmountRemoved: 3000, + quoteTokenAmountRemoved: 0.0053, + }, +}; + +describe('meteora AMM removeLiquidity — full-removal delegation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to closePosition at 100%, so the rent comes back', async () => { + (closePosition as jest.Mock).mockResolvedValue(CLOSED); + + const { removeLiquidity } = await import('../../../../src/connectors/meteora/amm-routes/removeLiquidity'); + const result = await removeLiquidity('mainnet-beta', WALLET, POOL, POSITION, 100, 1); + + expect(closePosition).toHaveBeenCalledWith('mainnet-beta', WALLET, POOL, POSITION, 1); + expect(result).toEqual(CLOSED); + // The refund is the whole point of the delegation: a plain withdrawal reports no + // such field, so its presence is what distinguishes the two paths. + expect(result.data?.positionRentRefunded).toBe(0.0099); + }); + + it('does not delegate below 100% — the position stays open and refunds nothing', async () => { + const { removeLiquidity } = await import('../../../../src/connectors/meteora/amm-routes/removeLiquidity'); + + // The partial path reaches the chain, which is not available here. What matters is + // that it got past the delegation branch rather than being answered by the mock. + await expect(removeLiquidity('mainnet-beta', WALLET, POOL, POSITION, 50, 1)).rejects.toBeDefined(); + expect(closePosition).not.toHaveBeenCalled(); + }); + + it.each([0.0001, 25, 99.9999])('does not delegate at %s%%', async (pct) => { + const { removeLiquidity } = await import('../../../../src/connectors/meteora/amm-routes/removeLiquidity'); + await expect(removeLiquidity('mainnet-beta', WALLET, POOL, POSITION, pct, 1)).rejects.toBeDefined(); + expect(closePosition).not.toHaveBeenCalled(); + }); + + it('rejects an out-of-range percentage before either path', async () => { + const { removeLiquidity } = await import('../../../../src/connectors/meteora/amm-routes/removeLiquidity'); + for (const pct of [0, -1, 100.1]) { + await expect(removeLiquidity('mainnet-beta', WALLET, POOL, POSITION, pct, 1)).rejects.toThrow( + /percentageToRemove must be between 0 and 100/, + ); + } + expect(closePosition).not.toHaveBeenCalled(); + }); +}); diff --git a/test/connectors/meteora/clmm-routes/create-pool.test.ts b/test/connectors/meteora/clmm-routes/create-pool.test.ts index cc6fa6c2a1..e96b6cb833 100644 --- a/test/connectors/meteora/clmm-routes/create-pool.test.ts +++ b/test/connectors/meteora/clmm-routes/create-pool.test.ts @@ -3,6 +3,7 @@ import { fastifyWithTypeProvider } from '../../../utils/testUtils'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', defaultWallet: '11111111111111111111111111111111', @@ -14,7 +15,7 @@ const SAME_MINT = 'So11111111111111111111111111111111111111112'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/meteora/clmm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-clmm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -45,7 +46,8 @@ describe('POST /create-pool (Meteora DLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: '82Sg8kkChhY7Qb2ptR4uLGqLg7Zm3z9v9tQ6Zb6Jk4iZ', baseToken: 'SOL', quoteToken: 'SOL', diff --git a/test/connectors/meteora/clmm-routes/execute-swap.test.ts b/test/connectors/meteora/clmm-routes/execute-swap.test.ts index 18d2eabf52..d8a0633072 100644 --- a/test/connectors/meteora/clmm-routes/execute-swap.test.ts +++ b/test/connectors/meteora/clmm-routes/execute-swap.test.ts @@ -3,6 +3,7 @@ import { PublicKey } from '@solana/web3.js'; import { Solana } from '../../../../src/chains/solana/solana'; import { Meteora } from '../../../../src/connectors/meteora/meteora'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/meteora/meteora'); @@ -16,6 +17,7 @@ jest.mock('../../../../src/services/pool-service', () => ({ }, })); jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', defaultWallet: '11111111111111111111111111111111', @@ -25,8 +27,8 @@ jest.mock('../../../../src/chains/solana/solana.config', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { executeSwapRoute } = await import('../../../../src/connectors/meteora/clmm-routes/executeSwap'); - await server.register(executeSwapRoute); + const { makeExecuteSwapRoute } = await import('../../../../src/trading/pool-swap-routes'); + await server.register(makeExecuteSwapRoute('clmm')); return server; }; @@ -121,6 +123,10 @@ describe('POST /execute-swap', () => { blockTime: Date.now() / 1000, }), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ + meta: { fee: 5000 }, + blockTime: Date.now() / 1000, + }), simulateWithErrorHandling: jest.fn().mockResolvedValue(undefined), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-0.1, 14.85], @@ -146,7 +152,8 @@ describe('POST /execute-swap', () => { method: 'POST', url: '/execute-swap', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: '11111111111111111111111111111111', poolAddress: mockPoolAddress, baseToken: 'SOL', @@ -158,16 +165,69 @@ describe('POST /execute-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('signature', mockTransaction.signature); expect(body).toHaveProperty('status', 1); - expect(body.data).toHaveProperty('amountIn', 0.1); + expect(Number(body.data.amountIn)).toBe(0.1); expect(body.data).toHaveProperty('amountOut', 14.85); expect(body.data).toHaveProperty('fee', 0.000005); // Fee in SOL expect(body.data).toHaveProperty('baseTokenBalanceChange', -0.1); expect(body.data).toHaveProperty('quoteTokenBalanceChange', 14.85); expect(body.data).toHaveProperty('tokenIn', mockSOL.address); expect(body.data).toHaveProperty('tokenOut', mockUSDC.address); + // The applied slippage is echoed on the execute response. + expect(body.data).toHaveProperty('slippagePct', 1); + }); + + it('fails loudly (400 TRANSACTION_FAILED) when the transaction landed on-chain but failed', async () => { + const { transactionFailed } = jest.requireActual('../../../../src/services/error-handler'); + const extractBalanceChangesAndFee = jest.fn(); + const mockSolanaInstance = { + getWallet: jest.fn().mockResolvedValue(mockWallet), + getToken: jest.fn((t: string) => { + if (t === 'SOL' || t === mockSOL.address) return Promise.resolve(mockSOL); + if (t === 'USDC' || t === mockUSDC.address) return Promise.resolve(mockUSDC); + return Promise.resolve(null); + }), + sendAndConfirmTransactionForWallet: jest.fn().mockResolvedValue({ + signature: mockTransaction.signature, + fee: 0.000005, + }), + // The route-level re-fetch surfaces a landed-but-failed transaction as a throw — + // it must never be reported as CONFIRMED (data exists) or PENDING. + getConfirmedTransactionData: jest + .fn() + .mockRejectedValue( + transactionFailed(`Transaction ${mockTransaction.signature} landed on-chain but failed: custom error 0x1771`), + ), + extractBalanceChangesAndFee, + }; + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); + + (Meteora.getInstance as jest.Mock).mockResolvedValue({ + getDlmmPool: jest.fn().mockResolvedValue(mockDlmmPool), + }); + + const response = await server.inject({ + method: 'POST', + url: '/execute-swap', + payload: { + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', + walletAddress: '11111111111111111111111111111111', + poolAddress: mockPoolAddress, + baseToken: 'SOL', + quoteToken: 'USDC', + amount: 0.1, + side: 'SELL', + slippagePct: 1, + }, + }); + + expect(response.statusCode).toBe(400); + expect(parseWire(response.body).message).toMatch(/landed on-chain but failed/); + // The route must not have tried to build a CONFIRMED response. + expect(extractBalanceChangesAndFee).not.toHaveBeenCalled(); }); it('should execute a CLMM swap for BUY side', async () => { @@ -194,6 +254,10 @@ describe('POST /execute-swap', () => { blockTime: Date.now() / 1000, }), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ + meta: { fee: 5000 }, + blockTime: Date.now() / 1000, + }), simulateWithErrorHandling: jest.fn().mockResolvedValue(undefined), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-15, 0.1], // For BUY: first is USDC (negative), second is SOL (positive) @@ -222,7 +286,8 @@ describe('POST /execute-swap', () => { method: 'POST', url: '/execute-swap', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: '11111111111111111111111111111111', poolAddress: mockPoolAddress, baseToken: 'SOL', @@ -234,10 +299,10 @@ describe('POST /execute-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('signature', mockTransaction.signature); expect(body).toHaveProperty('status', 1); - expect(body.data).toHaveProperty('amountIn', 15); // USDC in + expect(Number(body.data.amountIn)).toBe(15); // USDC in expect(body.data).toHaveProperty('amountOut', 0.1); // SOL out expect(body.data).toHaveProperty('tokenIn', mockUSDC.address); expect(body.data).toHaveProperty('tokenOut', mockSOL.address); @@ -261,7 +326,8 @@ describe('POST /execute-swap', () => { method: 'POST', url: '/execute-swap', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: '11111111111111111111111111111111', poolAddress: mockPoolAddress, baseToken: 'INVALID', @@ -275,7 +341,7 @@ describe('POST /execute-swap', () => { // Standardized wrapper derives the counter token from the pool; an unknown base token that // isn't one of the pool's tokens is a bad request (400). expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); }); diff --git a/test/connectors/meteora/clmm-routes/fetchPools.test.ts b/test/connectors/meteora/clmm-routes/fetchPools.test.ts index a4db076aac..3a7b4855c7 100644 --- a/test/connectors/meteora/clmm-routes/fetchPools.test.ts +++ b/test/connectors/meteora/clmm-routes/fetchPools.test.ts @@ -1,8 +1,10 @@ import { Meteora } from '../../../../src/connectors/meteora/meteora'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/connectors/meteora/meteora'); jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', defaultWallet: '11111111111111111111111111111111', @@ -12,7 +14,7 @@ jest.mock('../../../../src/chains/solana/solana.config', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { fetchPoolsRoute } = await import('../../../../src/connectors/meteora/clmm-routes/fetchPools'); + const { fetchPoolsRoute } = await import('../../../../src/trading/trading-clmm-routes/fetchPools'); await server.register(fetchPoolsRoute); return server; }; @@ -112,11 +114,11 @@ describe('GET /fetch-pools (Meteora)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=meteora', }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('pools'); expect(body).toHaveProperty('total', 81391); @@ -155,11 +157,11 @@ describe('GET /fetch-pools (Meteora)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&query=SOL-USDC&limit=10', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=meteora&query=SOL-USDC&limit=10', }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.pools).toHaveLength(1); expect(body.pools[0].name).toBe('SOL-USDC'); @@ -181,7 +183,7 @@ describe('GET /fetch-pools (Meteora)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&sortBy=tvl:desc', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=meteora&sortBy=tvl:desc', }); expect(response.statusCode).toBe(200); @@ -200,11 +202,11 @@ describe('GET /fetch-pools (Meteora)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=meteora', }); expect(response.statusCode).toBe(500); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should handle empty pool results', async () => { @@ -220,11 +222,11 @@ describe('GET /fetch-pools (Meteora)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&query=NONEXISTENT', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=meteora&query=NONEXISTENT', }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.pools).toHaveLength(0); expect(body.total).toBe(0); }); @@ -242,7 +244,7 @@ describe('GET /fetch-pools (Meteora)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&includeUnverified=false', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=meteora&includeUnverified=false', }); expect(response.statusCode).toBe(200); diff --git a/test/connectors/meteora/clmm-routes/positionsOwned.test.ts b/test/connectors/meteora/clmm-routes/positionsOwned.test.ts index d8423eac70..861c53c2c7 100644 --- a/test/connectors/meteora/clmm-routes/positionsOwned.test.ts +++ b/test/connectors/meteora/clmm-routes/positionsOwned.test.ts @@ -7,6 +7,7 @@ import { fastifyWithTypeProvider } from '../../../utils/testUtils'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/meteora/meteora'); jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', defaultWallet: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', @@ -16,7 +17,7 @@ jest.mock('../../../../src/chains/solana/solana.config', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { positionsOwnedRoute } = await import('../../../../src/connectors/meteora/clmm-routes/positionsOwned'); + const { positionsOwnedRoute } = await import('../../../../src/trading/clmm/positions-owned'); await server.register(positionsOwnedRoute); return server; }; @@ -87,7 +88,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: mockWalletAddress, }, }); @@ -116,7 +118,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: mockWalletAddress, }, }); @@ -132,7 +135,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', walletAddress: 'invalid-address', }, }); @@ -140,16 +144,27 @@ describe('GET /positions-owned', () => { expect(response.statusCode).toBe(400); }); - it('should return 400 when walletAddress is missing', async () => { + // The refactor gave walletAddress a schema default, so an omitted wallet is filled + // from the chain config rather than rejected. Only a malformed one still fails + // (above). Asserted here rather than in the raydium/pancakeswap-sol copies of this + // file because only this one mocks the Solana config, so only here is the filled + // value something the test knows. + it('fills an omitted wallet from the chain config instead of rejecting', async () => { + const getAllPositionsForWallet = jest.fn().mockResolvedValue(mockPositions); + (Meteora.getInstance as jest.Mock).mockResolvedValue({ getAllPositionsForWallet }); + const response = await app.inject({ method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', }, }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(200); + // The connector hands the SDK a PublicKey, not the raw string it was given. + expect(getAllPositionsForWallet.mock.calls[0][0].toBase58()).toBe(mockWalletAddress); }); it('should use default network if not provided', async () => { @@ -162,6 +177,7 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { + connector: 'meteora', walletAddress: mockWalletAddress, }, }); diff --git a/test/connectors/meteora/clmm-routes/quote-swap-impact.test.ts b/test/connectors/meteora/clmm-routes/quote-swap-impact.test.ts new file mode 100644 index 0000000000..10a6344ec5 --- /dev/null +++ b/test/connectors/meteora/clmm-routes/quote-swap-impact.test.ts @@ -0,0 +1,81 @@ +import { BN } from 'bn.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { Meteora } from '../../../../src/connectors/meteora/meteora'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/meteora/meteora'); +jest.mock('../../../../src/connectors/meteora/meteora.config', () => ({ + MeteoraConfig: { config: { slippagePct: 1 } }, +})); + +// This route returned `priceImpactPct: 0, // TODO` for every quote, so a swap of any size +// through a Meteora pool claimed zero impact and a caller could not tell that from a real +// measurement. Same defect class as a hardcoded fee of 0: the number is published with +// the same confidence as one that was computed. + +const SOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const USDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; + +const SPOT = 100; // USDC per SOL, from the active bin + +// Selling 10 SOL for 970 USDC: an executed price of 97 against a spot of 100 is 3% worse. +const SOLD = 10; +const RECEIVED = 970; + +const dlmmPool = { + tokenX: { publicKey: { toBase58: () => SOL.address }, mint: { decimals: 9 } }, + tokenY: { publicKey: { toBase58: () => USDC.address }, mint: { decimals: 6 } }, + getBinArrayForSwap: jest.fn().mockResolvedValue([]), + getActiveBin: jest.fn().mockResolvedValue({ pricePerToken: String(SPOT) }), + swapQuote: jest.fn().mockReturnValue({ + consumedInAmount: new BN(SOLD * 1e9), + outAmount: new BN(RECEIVED * 1e6), + minOutAmount: new BN(RECEIVED * 0.99 * 1e6), + }), + swapQuoteExactOut: jest.fn().mockReturnValue({ + inAmount: new BN(SOLD * 1e9), + maxInAmount: new BN(SOLD * 1.01 * 1e9), + outAmount: new BN(RECEIVED * 1e6), + }), +}; + +beforeEach(() => { + jest.clearAllMocks(); + (Solana.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn((t: string) => Promise.resolve(t === SOL.address || t === 'SOL' ? SOL : USDC)), + }); + (Meteora.getInstance as jest.Mock).mockResolvedValue({ + getDlmmPool: jest.fn().mockResolvedValue(dlmmPool), + }); +}); + +describe('meteora CLMM quote-swap price impact', () => { + it('measures the quote against the pool spot price', async () => { + const { quoteSwap } = await import('../../../../src/connectors/meteora/clmm-routes/quoteSwap'); + + const quote = await quoteSwap('mainnet-beta', 'pool', 'SOL', 'SELL', SOLD); + + // 97 executed against 100 spot. + expect(quote.priceImpactPct).toBeCloseTo(3, 6); + }); + + it('reports a percentage, not a fraction', async () => { + const { quoteSwap } = await import('../../../../src/connectors/meteora/clmm-routes/quoteSwap'); + + const quote = await quoteSwap('mainnet-beta', 'pool', 'SOL', 'SELL', SOLD); + + // The units this whole issue is about: 3 means three percent, not three hundred. + expect(quote.priceImpactPct).toBeGreaterThan(1); + expect(quote.priceImpactPct).toBeLessThan(10); + }); + + it('reports zero when the quote executes at spot', async () => { + dlmmPool.getActiveBin.mockResolvedValueOnce({ pricePerToken: String(RECEIVED / SOLD) }); + const { quoteSwap } = await import('../../../../src/connectors/meteora/clmm-routes/quoteSwap'); + + const quote = await quoteSwap('mainnet-beta', 'pool', 'SOL', 'SELL', SOLD); + + expect(quote.priceImpactPct).toBe(0); + }); +}); diff --git a/test/connectors/meteora/clmm.test.js b/test/connectors/meteora/clmm.test.js deleted file mode 100644 index 9879334683..0000000000 --- a/test/connectors/meteora/clmm.test.js +++ /dev/null @@ -1,954 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'meteora'; -const PROTOCOL = 'clmm'; -const NETWORK = 'mainnet-beta'; -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const TEST_POOL = '5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6'; // SOL-USDC Meteora CLMM pool -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; -const TEST_POSITION_ID = '123456789'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate pool info response structure -function validatePoolInfo(response) { - return ( - response && - typeof response.address === 'string' && - typeof response.baseTokenAddress === 'string' && - typeof response.quoteTokenAddress === 'string' && - typeof response.feePct === 'number' && - typeof response.price === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.activeBinId === 'number' && - typeof response.binStep === 'number' - ); -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' && - typeof response.computeUnits === 'number' // Updated to use computeUnits - ); -} - -// Function to validate position info response structure -function validatePositionInfo(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.positionId === 'string' && - typeof response.lowerTick === 'number' && - typeof response.upperTick === 'number' && - typeof response.liquidity === 'string' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.unclaimedFeeBaseAmount === 'number' && - typeof response.unclaimedFeeQuoteAmount === 'number' - ); -} - -// Function to validate quote position response -function validateQuotePosition(response) { - return ( - response && - typeof response.baseLimited === 'boolean' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.baseTokenAmountMax === 'number' && - typeof response.quoteTokenAmountMax === 'number' && - response.liquidity !== undefined && // Can be string or object - typeof response.computeUnits === 'number' // Added computeUnits - ); -} - -// Function to validate swap execution response structure -function validateSwapExecution(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.tokenIn === 'string' && - typeof response.data.tokenOut === 'string' && - typeof response.data.amountIn === 'number' && - typeof response.data.amountOut === 'number' && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenBalanceChange === 'number' && - typeof response.data.quoteTokenBalanceChange === 'number')) - ); -} - -// Function to validate open position response -function validateOpenPosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.positionAddress === 'string' && - typeof response.data.positionRent === 'number' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate add liquidity response -function validateAddLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate remove liquidity response -function validateRemoveLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number')) - ); -} - -// Function to validate close position response -function validateClosePosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.positionRentRefunded === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number' && - typeof response.data.baseFeeAmountCollected === 'number' && - typeof response.data.quoteFeeAmountCollected === 'number')) - ); -} - -// Tests -describe('Meteora CLMM Tests (Solana Mainnet)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Pool Info Endpoint', () => { - test('returns and validates pool info', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-pool-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePoolInfo(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.address).toBe(TEST_POOL); - expect(response.data.feePct).toBeGreaterThanOrEqual(0.01); // Typical Meteora CLMM fee - expect(response.data.activeBinId).toBeDefined(); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - poolAddress: TEST_POOL, - }), - }), - ); - }); - - test('handles error for non-existent pool', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Pool not found for SOL-UNKNOWN', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: 'UNKNOWN', - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-quote-swap-sell'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - }); - - test('returns and validates swap quote for BUY', async () => { - // Modify the mock response for BUY direction - const mockSellResponse = loadMockResponse('swap-quote'); - const mockBuyResponse = { - ...mockSellResponse, - estimatedAmountIn: mockSellResponse.estimatedAmountOut, - estimatedAmountOut: mockSellResponse.estimatedAmountIn, - maxAmountIn: mockSellResponse.estimatedAmountOut * 1.01, - minAmountOut: mockSellResponse.estimatedAmountIn * 0.99, - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.quoteTokenBalanceChange, // Negative for BUY - computeUnits: 200000, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - - test('handles insufficient liquidity error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient liquidity in pool', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1000000.0, // Very large amount - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient liquidity'), - }, - }, - }); - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Load mock response - const executeResponse = loadMockResponse('swap-execute'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapExecution(response.data)).toBe(true); - expect(response.data.signature).toBeDefined(); - expect(response.data.signature.length).toBeGreaterThan(80); // Solana signatures are long - expect(response.data.status).toBe(1); // CONFIRMED - expect(response.data.data.tokenIn).toBeDefined(); - expect(response.data.data.tokenOut).toBeDefined(); - }); - - test('handles transaction simulation error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 500, - data: { - error: 'InternalServerError', - message: 'Transaction simulation failed', - code: 500, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1000000.0, - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 500, - data: { - error: 'InternalServerError', - }, - }, - }); - }); - }); - - describe('Position Info Endpoint', () => { - test('returns and validates position info', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-position-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: TEST_POSITION_ID, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePositionInfo(response.data)).toBe(true); - expect(response.data.positionId).toBe(TEST_POSITION_ID); - expect(response.data.liquidity).toBeDefined(); - }); - - test('handles position not found error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Position not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: 'invalid-position', - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Positions Owned Endpoint', () => { - test('returns list of owned positions', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-positions-owned'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/positions-owned`, { - params: { - network: NETWORK, - walletAddress: TEST_WALLET, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(Array.isArray(response.data)).toBe(true); - - // If there are positions, validate the first one - if (response.data.length > 0) { - const firstPosition = response.data[0]; - expect(validatePositionInfo(firstPosition)).toBe(true); - } - }); - - test('handles empty positions list', async () => { - // Setup mock axios with empty array - axios.get.mockResolvedValueOnce({ - status: 200, - data: [], - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/positions-owned`, { - params: { - network: NETWORK, - walletAddress: 'EmptyWallet123456789', - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(Array.isArray(response.data)).toBe(true); - expect(response.data.length).toBe(0); - }); - }); - - describe('Quote Position Endpoint', () => { - test('returns and validates quote for new position', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - lowerTick: -88720, - upperTick: 88720, - baseLimited: false, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - baseTokenAmountMax: 1.0, - quoteTokenAmountMax: 167.5, - liquidity: '1294000000', - shareOfPool: 0.0001, - computeUnits: 150000, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -88720, - upperTick: 88720, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateQuotePosition(response.data)).toBe(true); - expect(response.data.liquidity).toBeDefined(); - expect(response.data.shareOfPool).toBeGreaterThan(0); - }); - - test('handles invalid tick range error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Invalid tick range: lower tick must be less than upper tick', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: 100, - upperTick: 50, // Invalid: upper < lower - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Invalid tick range'), - }, - }, - }); - }); - - test('handles ticks not aligned with tick spacing error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Ticks must be aligned with tick spacing', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -88721, // Not aligned with tick spacing - upperTick: 88720, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('tick spacing'), - }, - }, - }); - }); - }); - - describe('Open Position Endpoint', () => { - test('returns successful position opening', async () => { - const mockResponse = loadMockResponse('clmm-open-position'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -88720, - upperTick: 88720, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBeDefined(); - expect(response.data.liquidity).toBeDefined(); - }); - - test('handles insufficient balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient balance for SOL', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -88720, - upperTick: 88720, - baseTokenAmount: 10000.0, // Large amount - quoteTokenAmount: 1675000.0, - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient balance'), - }, - }, - }); - }); - }); - - describe('Add Liquidity Endpoint', () => { - test('returns successful liquidity addition to existing position', async () => { - const mockResponse = loadMockResponse('clmm-add-liquidity'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - baseTokenAmount: 0.5, - quoteTokenAmount: 83.75, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe(TEST_POSITION_ID); - expect(response.data.liquidity).toBeDefined(); - }); - - test('handles position not found error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Position not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - positionId: 'invalid-position', - baseTokenAmount: 0.5, - quoteTokenAmount: 83.75, - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Remove Liquidity Endpoint', () => { - test('returns successful liquidity removal', async () => { - const mockResponse = { - signature: '4bF7KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpU5Ay', - positionId: TEST_POSITION_ID, - baseTokenAmount: 0.95, - quoteTokenAmount: 159.125, - liquidityRemoved: '647000000', - fee: 0.005, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - liquidity: '647000000', - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - expect(response.data.liquidityRemoved).toBe('647000000'); - }); - - test('handles invalid liquidity amount error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Liquidity amount exceeds position liquidity', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - liquidity: '9999999999999999999', // Excessive amount - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('exceeds position liquidity'), - }, - }, - }); - }); - }); - - describe('Close Position Endpoint', () => { - test('returns successful position closure', async () => { - const mockResponse = { - signature: '5cG8KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpV6Bz', - positionId: TEST_POSITION_ID, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - feeBaseAmount: 0.01, - feeQuoteAmount: 1.675, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/close-position`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe(TEST_POSITION_ID); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - }); - - test('handles position already closed error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Position already closed', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/close-position`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('already closed'), - }, - }, - }); - }); - }); - - describe('Collect Fees Endpoint', () => { - test('returns successful fee collection', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-collect-fees'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/collect-fees`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.feeBaseAmount).toBeGreaterThanOrEqual(0); - expect(response.data.feeQuoteAmount).toBeGreaterThanOrEqual(0); - }); - - test('handles no fees to collect', async () => { - const mockResponse = { - signature: '7eH9KihZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpX8Ed', - positionId: TEST_POSITION_ID, - feeBaseAmount: 0, - feeQuoteAmount: 0, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/collect-fees`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.feeBaseAmount).toBe(0); - expect(response.data.feeQuoteAmount).toBe(0); - }); - }); -}); diff --git a/test/connectors/meteora/meteora.routes.test.ts b/test/connectors/meteora/meteora.routes.test.ts deleted file mode 100644 index 0ebaf342b4..0000000000 --- a/test/connectors/meteora/meteora.routes.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('Meteora Routes Structure', () => { - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should have clmm-routes (DLMM) and amm-routes (DAMM v2) folders', () => { - const meteoraPath = path.join(__dirname, '../../../src/connectors/meteora'); - const clmmRoutesPath = path.join(meteoraPath, 'clmm-routes'); - const ammRoutesPath = path.join(meteoraPath, 'amm-routes'); - const swapRoutesPath = path.join(meteoraPath, 'swap-routes'); - const routesPath = path.join(meteoraPath, 'routes'); - - expect(fs.existsSync(clmmRoutesPath)).toBe(true); - expect(fs.existsSync(ammRoutesPath)).toBe(true); - expect(fs.existsSync(swapRoutesPath)).toBe(false); - expect(fs.existsSync(routesPath)).toBe(false); - }); - - it('should have swap endpoints within CLMM routes', () => { - const clmmRoutesPath = path.join(__dirname, '../../../src/connectors/meteora/clmm-routes'); - const files = fs.readdirSync(clmmRoutesPath); - - expect(files).toContain('executeSwap.ts'); - expect(files).toContain('quoteSwap.ts'); - }); - }); - - describe('Route Registration', () => { - it('should register Meteora CLMM (DLMM) and AMM (DAMM v2) routes', async () => { - // commonPrefix:false prints full paths (no radix-tree prefix collapsing). - const routes = fastify.printRoutes({ commonPrefix: false }); - - // Check that Meteora CLMM routes are registered - expect(routes).toContain('meteora/clmm/'); - - // Check that Meteora AMM (DAMM v2) routes are registered - expect(routes).toContain('meteora/amm/'); - - // Check that swap routes are NOT directly under /swap - expect(routes).not.toContain('meteora/swap/'); - }); - }); -}); diff --git a/test/connectors/meteora/mocks/balance.json b/test/connectors/meteora/mocks/balance.json deleted file mode 100644 index c92760b58e..0000000000 --- a/test/connectors/meteora/mocks/balance.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "validResponse": { - "balances": { - "SOL": 2.5, - "USDC": 1000.0, - "USDT": 500.0 - } - }, - "invalidResponse": { - "balances": { - "SOL": "2.5", - "USDC": 1000.0, - "USDT": 500.0 - } - } - }, - { - "validResponse": { - "balances": { - "SOL": 2.5, - "USDC": 1000.0, - "USDT": 500.0 - } - }, - "invalidResponse": { - "balances": [ - { "symbol": "SOL", "amount": 2.5 }, - { "symbol": "USDC", "amount": 1000.0 }, - { "symbol": "USDT", "amount": 500.0 } - ] - } - } -] \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-add-liquidity.json b/test/connectors/meteora/mocks/clmm-add-liquidity.json deleted file mode 100644 index 79028384fc..0000000000 --- a/test/connectors/meteora/mocks/clmm-add-liquidity.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "signature": "4Kb31qfpWHMiawHVz3oP4oWLEUvnxpSbP1RVhKzLnUHHy42782RvN9Fz5QsKrFHJbfMeGJnKVGBKs7x2ZQgv6Xre", - "fee": 0.001, - "positionId": "123456789", - "liquidity": "1500000000000000000", - "baseTokenAmountAdded": 1.0, - "quoteTokenAmountAdded": 167.5 -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-collect-fees.json b/test/connectors/meteora/mocks/clmm-collect-fees.json deleted file mode 100644 index e298e803c3..0000000000 --- a/test/connectors/meteora/mocks/clmm-collect-fees.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "signature": "4Kb31qfpWHMiawHVz3oP4oWLEUvnxpSbP1RVhKzLnUHHy42782RvN9Fz5QsKrFHJbfMeGJnKVGBKs7x2ZQgv6Xre", - "fee": 0.001, - "feeBaseAmount": 0.001, - "feeQuoteAmount": 0.1675 -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-open-position.json b/test/connectors/meteora/mocks/clmm-open-position.json deleted file mode 100644 index df42be2ce2..0000000000 --- a/test/connectors/meteora/mocks/clmm-open-position.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "signature": "5KtPn1LGuxhPdNK7Lr13Jxj5JBAf7xTEEBs4iFBtVGZSDYgAQECXfXnGQ2jiAiRHNLbz8pT8xcTKfPZ9jQpKGNZR", - "positionId": "123456789", - "poolAddress": "CS2H8nbAVVEUHWPF5extCSymqheQdkd4d7thik6eet9N", - "lowerTick": -88720, - "upperTick": 88720, - "liquidity": "1294000000", - "baseTokenAmount": 1.0, - "quoteTokenAmount": 167.5, - "fee": 0.005 -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-pool-info.json b/test/connectors/meteora/mocks/clmm-pool-info.json deleted file mode 100644 index e936af21d9..0000000000 --- a/test/connectors/meteora/mocks/clmm-pool-info.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "address": "5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.04, - "dynamicFeePct": 0.0411578, - "price": 163.6479153592644, - "baseTokenAmount": 30318.675104312, - "quoteTokenAmount": 1249165.517756, - "activeBinId": -4526, - "binStep": 4, - "minBinId": -109192, - "maxBinId": 109192 -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-position-info-not-found.json b/test/connectors/meteora/mocks/clmm-position-info-not-found.json deleted file mode 100644 index e9cd8f027b..0000000000 --- a/test/connectors/meteora/mocks/clmm-position-info-not-found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 404, - "error": "NotFound", - "message": "Position not found" -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-position-info.json b/test/connectors/meteora/mocks/clmm-position-info.json deleted file mode 100644 index 0b1d4a107a..0000000000 --- a/test/connectors/meteora/mocks/clmm-position-info.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "CS2H8nbAVVEUHWPF5extCSymqheQdkd4d7thik6eet9N", - "positionId": "123456789", - "lowerTick": -88720, - "upperTick": 88720, - "liquidity": "1000000000000000000", - "baseTokenAmount": 1.5, - "quoteTokenAmount": 251.25, - "unclaimedFeeBaseAmount": 0.001, - "unclaimedFeeQuoteAmount": 0.1675 -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-positions-owned.json b/test/connectors/meteora/mocks/clmm-positions-owned.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/test/connectors/meteora/mocks/clmm-positions-owned.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-quote-position-invalid-range.json b/test/connectors/meteora/mocks/clmm-quote-position-invalid-range.json deleted file mode 100644 index 1d4d9513ec..0000000000 --- a/test/connectors/meteora/mocks/clmm-quote-position-invalid-range.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Route GET:/connectors/meteora/clmm/quote-position?network=mainnet-beta&poolAddress=CS2H8nbAVVEUHWPF5extCSymqheQdkd4d7thik6eet9N&lowerTick=88720&upperTick=-88720&baseAmount=0.1"eAmount=16 not found", - "error": "Not Found", - "statusCode": 404 -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-quote-swap-buy.json b/test/connectors/meteora/mocks/clmm-quote-swap-buy.json deleted file mode 100644 index a8dc0a08f6..0000000000 --- a/test/connectors/meteora/mocks/clmm-quote-swap-buy.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "InternalServerError", - "message": "Internal server error" -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-quote-swap-insufficient-liquidity.json b/test/connectors/meteora/mocks/clmm-quote-swap-insufficient-liquidity.json deleted file mode 100644 index a8dc0a08f6..0000000000 --- a/test/connectors/meteora/mocks/clmm-quote-swap-insufficient-liquidity.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "InternalServerError", - "message": "Internal server error" -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-quote-swap-invalid-token.json b/test/connectors/meteora/mocks/clmm-quote-swap-invalid-token.json deleted file mode 100644 index 849d546802..0000000000 --- a/test/connectors/meteora/mocks/clmm-quote-swap-invalid-token.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 404, - "error": "NotFoundError", - "message": "Request failed" -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/clmm-quote-swap-sell.json b/test/connectors/meteora/mocks/clmm-quote-swap-sell.json deleted file mode 100644 index 9162665100..0000000000 --- a/test/connectors/meteora/mocks/clmm-quote-swap-sell.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6", - "estimatedAmountIn": 0.01, - "estimatedAmountOut": 1.633209, - "minAmountOut": 1.616876, - "maxAmountIn": 0.01, - "baseTokenBalanceChange": -0.01, - "quoteTokenBalanceChange": 1.633209, - "price": 163.3209, - "computeUnits": 200000 -} \ No newline at end of file diff --git a/test/connectors/meteora/mocks/swap-execute.json b/test/connectors/meteora/mocks/swap-execute.json deleted file mode 100644 index 1880812bd0..0000000000 --- a/test/connectors/meteora/mocks/swap-execute.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "signature": "4Kb31qfpWHMiawHVz3oP4oWLEUvnxpSbP1RVhKzLnUHHy42782RvN9Fz5QsKrFHJbfMeGJnKVGBKs7x2ZQgv6Xre", - "status": 1, - "data": { - "tokenIn": "So11111111111111111111111111111111111111112", - "tokenOut": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "amountIn": 1.0, - "amountOut": 167.5, - "fee": 0.001, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 167.5 - } -} diff --git a/test/connectors/meteora/mocks/swap-quote.json b/test/connectors/meteora/mocks/swap-quote.json deleted file mode 100644 index 0c3d23c7cf..0000000000 --- a/test/connectors/meteora/mocks/swap-quote.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "poolAddress": "CS2H8nbAVVEUHWPF5extCSymqheQdkd4d7thik6eet9N", - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 167.5, - "minAmountOut": 166.1625, - "maxAmountIn": 1.01, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 167.5, - "price": 167.5, - "gasPrice": 0.0005, - "gasLimit": 300000, - "gasCost": 0.15 -} \ No newline at end of file diff --git a/test/connectors/okx/okx.routes.test.ts b/test/connectors/okx/okx.routes.test.ts deleted file mode 100644 index 3ed4c969c4..0000000000 --- a/test/connectors/okx/okx.routes.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('OKX Routes Structure', () => { - const CONNECTOR_NAME = 'okx'; - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should have appropriate route folders based on trading types', async () => { - const response = await fastify.inject({ - method: 'GET', - url: '/config/connectors', - }); - - const { connectors } = JSON.parse(response.body); - const okxConfig = connectors.find((c: any) => c.name === CONNECTOR_NAME); - - expect(okxConfig).toBeDefined(); - expect(okxConfig.chain).toBe('solana'); - expect(okxConfig.trading_types).toEqual(['router']); - expect(okxConfig.networks).toEqual(['mainnet-beta']); - - const connectorPath = path.join(__dirname, `../../../src/connectors/${CONNECTOR_NAME}`); - const routerRoutesPath = path.join(connectorPath, 'router-routes'); - expect(fs.existsSync(routerRoutesPath)).toBe(true); - - const files = fs.readdirSync(routerRoutesPath); - expect(files.some((f) => f.toLowerCase().includes('swap'))).toBe(true); - }); - }); - - describe('Route Registration', () => { - it('should register OKX router routes at /connectors/okx/router', async () => { - // printRoutes compresses shared prefixes (okx/orca), so probe the routes directly: - // a registered route responds with validation/handler errors, an absent one with 404 - const quoteSwap = await fastify.inject({ method: 'GET', url: '/connectors/okx/router/quote-swap' }); - expect(quoteSwap.statusCode).not.toBe(404); - - const executeSwap = await fastify.inject({ method: 'POST', url: '/connectors/okx/router/execute-swap' }); - expect(executeSwap.statusCode).not.toBe(404); - }); - }); -}); diff --git a/test/connectors/okx/router-routes/executeQuote.test.ts b/test/connectors/okx/router-routes/executeQuote.test.ts index 4ee20ef1e6..8df9641f81 100644 --- a/test/connectors/okx/router-routes/executeQuote.test.ts +++ b/test/connectors/okx/router-routes/executeQuote.test.ts @@ -2,6 +2,7 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Okx } from '../../../../src/connectors/okx/okx'; import { quoteCache } from '../../../../src/services/quote-cache'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/okx/okx'); @@ -9,7 +10,7 @@ jest.mock('../../../../src/connectors/okx/okx'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { executeQuoteRoute } = await import('../../../../src/connectors/okx/router-routes/executeQuote'); + const { executeQuoteRoute } = await import('../../../../src/trading/trading-router-routes/executeQuote'); await server.register(executeQuoteRoute); return server; }; @@ -29,6 +30,7 @@ const confirmedResult = { fee: 0.000005, baseTokenBalanceChange: -0.1, quoteTokenBalanceChange: 15, + slippagePct: 0.5, }, }; @@ -53,6 +55,7 @@ describe('POST /execute-quote (okx)', () => { const mockSolanaInstance = { sendAndConfirmTransactionForWallet, connection: { getTransaction: jest.fn(async () => ({ meta: {} })) }, + getConfirmedTransactionData: jest.fn(async () => ({ meta: {} })), handleConfirmation: jest.fn(async () => confirmedResult), }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); @@ -75,11 +78,14 @@ describe('POST /execute-quote (okx)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: WALLET, network: 'mainnet-beta', quoteId: 'okx-quote-1' }, + body: { walletAddress: WALLET, chainNetwork: 'solana-mainnet-beta', connector: 'okx', quoteId: 'okx-quote-1' }, }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body)).toMatchObject({ signature: 'okx-sig', status: 1 }); + const body = parseWire(response.body); + expect(body).toMatchObject({ signature: 'okx-sig', status: 1 }); + // The applied slippage survives the SwapExecuteResponse serializer. + expect(Number(body.data.slippagePct)).toBe(0.5); // The route is re-fetched with the executing wallet and the cached parameters. expect(getSwapTransaction).toHaveBeenCalledWith( WALLET, @@ -90,6 +96,18 @@ describe('POST /execute-quote (okx)', () => { 0.5, ); expect(sendAndConfirmTransactionForWallet).toHaveBeenCalledWith(unsignedTx, WALLET); + // The confirmation helper receives the retry-fetched txData and the applied slippage + // so it can decide the status (never `txData !== null`) and echo slippagePct. + expect(mockSolanaInstance.getConfirmedTransactionData).toHaveBeenCalledWith('okx-sig'); + expect(mockSolanaInstance.handleConfirmation).toHaveBeenCalledWith( + 'okx-sig', + { meta: {} }, + mockSOL.address, + mockUSDC.address, + WALLET, + undefined, + 0.5, + ); expect(quoteCache.get('okx-quote-1')).toBeNull(); }); @@ -97,10 +115,10 @@ describe('POST /execute-quote (okx)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: WALLET, network: 'mainnet-beta', quoteId: 'missing-quote' }, + body: { walletAddress: WALLET, chainNetwork: 'solana-mainnet-beta', connector: 'okx', quoteId: 'missing-quote' }, }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toContain('Quote not found or expired'); + expect(parseWire(response.body).message).toContain('Quote not found or expired'); }); }); diff --git a/test/connectors/okx/router-routes/quoteSwap.test.ts b/test/connectors/okx/router-routes/quoteSwap.test.ts index 6dac16c797..987a2500f6 100644 --- a/test/connectors/okx/router-routes/quoteSwap.test.ts +++ b/test/connectors/okx/router-routes/quoteSwap.test.ts @@ -1,6 +1,7 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Okx } from '../../../../src/connectors/okx/okx'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/okx/okx'); @@ -8,7 +9,7 @@ jest.mock('../../../../src/connectors/okx/okx'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/okx/router-routes/quoteSwap'); + const { quoteSwapRoute } = await import('../../../../src/trading/trading-router-routes/quoteSwap'); await server.register(quoteSwapRoute); return server; }; @@ -65,7 +66,8 @@ describe('GET /quote-swap (okx)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'okx', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -75,15 +77,17 @@ describe('GET /quote-swap (okx)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId'); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut', 15); expect(body).toHaveProperty('price', 150); expect(body).toHaveProperty('priceImpactPct', 0.05); expect(body).toHaveProperty('tokenIn', mockSOL.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); - expect(body).toHaveProperty('routerResult'); + // The connector's raw provider payload (quoteResponse / routerResult) is not part + // of the unified router response schema, which serializes the shared quote fields + // plus quoteId. Assertions on it moved out with the per-connector route. expect(body.approximation).toBeUndefined(); expect(mockOkxInstance.getQuote).toHaveBeenCalledWith(mockSOL.address, mockUSDC.address, '100000000', 'exactIn'); @@ -104,7 +108,8 @@ describe('GET /quote-swap (okx)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'okx', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -114,8 +119,8 @@ describe('GET /quote-swap (okx)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); - expect(body).toHaveProperty('amountIn', 15); + const body = parseWire(response.body); + expect(Number(body.amountIn)).toBe(15); expect(body).toHaveProperty('amountOut', 0.1); expect(body.approximation).toBeUndefined(); // maxAmountIn includes slippage buffer for native exactOut @@ -150,7 +155,8 @@ describe('GET /quote-swap (okx)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'okx', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -160,9 +166,9 @@ describe('GET /quote-swap (okx)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('approximation', true); - expect(body).toHaveProperty('amountIn', 15); + expect(Number(body.amountIn)).toBe(15); expect(body.amountOut).toBeCloseTo(0.0999); expect(body.maxAmountIn).toBeCloseTo(15); expect(body.minAmountOut).toBeCloseTo(0.0999 * (1 - 0.005)); @@ -180,7 +186,8 @@ describe('GET /quote-swap (okx)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'okx', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -191,7 +198,7 @@ describe('GET /quote-swap (okx)', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.message).toContain('No route found'); expect(mockOkxInstance.getQuote).toHaveBeenCalledTimes(1); }); @@ -207,7 +214,8 @@ describe('GET /quote-swap (okx)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'okx', baseToken: 'INVALID', quoteToken: 'USDC', amount: '0.1', @@ -216,6 +224,6 @@ describe('GET /quote-swap (okx)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); }); diff --git a/test/connectors/okx/schemas.test.ts b/test/connectors/okx/schemas.test.ts index fc9093609f..9b2465d199 100644 --- a/test/connectors/okx/schemas.test.ts +++ b/test/connectors/okx/schemas.test.ts @@ -5,34 +5,6 @@ import * as Base from '../../../src/schemas/router-schema'; describe('OKX Schema Tests', () => { describe('Schema Superset Validation', () => { - it('OkxQuoteSwapRequest should be a superset of QuoteSwapRequest', () => { - const baseRequired = Base.QuoteSwapRequest.required || []; - const okxRequired = Okx.OkxQuoteSwapRequest.required || []; - - for (const field of baseRequired) { - expect(okxRequired).toContain(field); - } - - const baseProps = Object.keys(Base.QuoteSwapRequest.properties); - const okxProps = Object.keys(Okx.OkxQuoteSwapRequest.properties); - - for (const prop of baseProps) { - expect(okxProps).toContain(prop); - } - - const sampleRequest = { - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.QuoteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(Okx.OkxQuoteSwapRequest, sampleRequest)).toBe(true); - }); - it('OkxQuoteSwapResponse should be a superset of QuoteSwapResponse', () => { const baseRequired = Base.QuoteSwapResponse.required || []; const okxRequired = Okx.OkxQuoteSwapResponse.required || []; @@ -48,68 +20,9 @@ describe('OKX Schema Tests', () => { expect(okxProps).toContain(prop); } }); - - it('OkxExecuteQuoteRequest should be a superset of ExecuteQuoteRequest', () => { - const baseRequired = Base.ExecuteQuoteRequest.required || []; - const okxRequired = Okx.OkxExecuteQuoteRequest.required || []; - - for (const field of baseRequired) { - expect(okxRequired).toContain(field); - } - - const baseProps = Object.keys(Base.ExecuteQuoteRequest.properties); - const okxProps = Object.keys(Okx.OkxExecuteQuoteRequest.properties); - - for (const prop of baseProps) { - expect(okxProps).toContain(prop); - } - - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - quoteId: '123e4567-e89b-12d3-a456-426614174000', - }; - - expect(Value.Check(Base.ExecuteQuoteRequest, sampleRequest)).toBe(true); - expect(Value.Check(Okx.OkxExecuteQuoteRequest, sampleRequest)).toBe(true); - }); - - it('OkxExecuteSwapRequest should be a superset of ExecuteSwapRequest', () => { - const baseRequired = Base.ExecuteSwapRequest.required || []; - const okxRequired = Okx.OkxExecuteSwapRequest.required || []; - - for (const field of baseRequired) { - expect(okxRequired).toContain(field); - } - - const baseProps = Object.keys(Base.ExecuteSwapRequest.properties); - const okxProps = Object.keys(Okx.OkxExecuteSwapRequest.properties); - - for (const prop of baseProps) { - expect(okxProps).toContain(prop); - } - - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.ExecuteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(Okx.OkxExecuteSwapRequest, sampleRequest)).toBe(true); - }); }); describe('OKX-specific Fields', () => { - it('OkxQuoteSwapRequest should include the BUY approximation flag', () => { - const props = Object.keys(Okx.OkxQuoteSwapRequest.properties); - expect(props).toContain('approximateIfNoExactOut'); - }); - it('OkxQuoteSwapResponse should include OKX-specific fields', () => { const props = Object.keys(Okx.OkxQuoteSwapResponse.properties); expect(props).toContain('routerResult'); @@ -117,11 +30,5 @@ describe('OKX Schema Tests', () => { }); }); - describe('Field Examples and Defaults', () => { - it('should have Solana mainnet-only network enum', () => { - const networkProp = Okx.OkxQuoteSwapRequest.properties.network; - expect(networkProp.default).toBe('mainnet-beta'); - expect(networkProp.enum).toEqual(['mainnet-beta']); - }); - }); + describe('Field Examples and Defaults', () => {}); }); diff --git a/test/connectors/orca/clmm-routes/addLiquidity.test.ts b/test/connectors/orca/clmm-routes/addLiquidity.test.ts index 243b309f70..46b16f6097 100644 --- a/test/connectors/orca/clmm-routes/addLiquidity.test.ts +++ b/test/connectors/orca/clmm-routes/addLiquidity.test.ts @@ -1,14 +1,86 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +// This previously mocked an `orca.addLiquidity()` the connector never calls, then +// accepted [200, 400, 500] — so the success cases passed on a 500 from the unmocked SDK. +// The mocks below are what addLiquidity actually calls. +const mockFetchPosition = jest.fn(); +const mockFetchWhirlpool = jest.fn(); +const mockFetchAllMint = jest.fn(); +const mockIncreaseLiquidity = jest.fn(); +const mockQuoteA = jest.fn(); +const mockQuoteB = jest.fn(); +const mockSendAndConfirm = jest.fn(); jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/orca/orca'); +jest.mock('@orca-so/whirlpools-client', () => ({ + fetchPosition: (...a: any[]) => mockFetchPosition(...a), + fetchWhirlpool: (...a: any[]) => mockFetchWhirlpool(...a), +})); +jest.mock('@orca-so/whirlpools', () => ({ + increaseLiquidityInstructions: (...a: any[]) => mockIncreaseLiquidity(...a), +})); +jest.mock('@orca-so/whirlpools-core', () => ({ + increaseLiquidityQuoteA: (...a: any[]) => mockQuoteA(...a), + increaseLiquidityQuoteB: (...a: any[]) => mockQuoteB(...a), +})); +jest.mock('@solana-program/token-2022', () => ({ + fetchAllMint: (...a: any[]) => mockFetchAllMint(...a), +})); +jest.mock('../../../../src/connectors/orca/orca.sdk', () => ({ + buildOrcaTransaction: jest.fn().mockReturnValue({ tx: true }), + createOrcaAuthority: jest.fn().mockReturnValue('authority'), + replaceOrcaInstructionAccounts: jest.fn((i: any) => i), +})); + +const POOL = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; +const SOL = 'So11111111111111111111111111111111111111112'; +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +// tokenMax* is what increaseLiquidityQuote* returns after applying slippage to tokenEst*; +// they are kept distinct here so a test cannot pass on the wrong one by accident. +const QUOTE = { + liquidityDelta: 500_000n, + tokenEstA: 1_000_000_000n, + tokenEstB: 200_000_000n, + tokenMaxA: 1_010_000_000n, + tokenMaxB: 202_000_000n, +}; + +/** The Orca and Solana surface addLiquidity actually touches. */ +const seedConnector = ({ added = [1, 200] as [number, number] } = {}) => { + (Orca.getInstance as jest.Mock).mockResolvedValue({ + solanaKitRpc: { getEpochInfo: () => ({ send: async () => ({ epoch: 1 }) }) }, + deployment: 'mainnet', + }); + mockFetchPosition.mockResolvedValue({ + data: { whirlpool: POOL, positionMint: 'mint', tickLowerIndex: -100, tickUpperIndex: 100 }, + }); + mockFetchWhirlpool.mockResolvedValue({ + data: { tokenMintA: SOL, tokenMintB: USDC, tickCurrentIndex: 0, sqrtPrice: 1n }, + }); + // token-2022 mints carry an extensions option; the transfer-fee lookup reads it. + mockFetchAllMint.mockResolvedValue([ + { data: { decimals: 9, extensions: { __option: 'None' } } }, + { data: { decimals: 6, extensions: { __option: 'None' } } }, + ]); + mockQuoteA.mockReturnValue(QUOTE); + mockQuoteB.mockReturnValue(QUOTE); + mockIncreaseLiquidity.mockResolvedValue({ instructions: [], quote: QUOTE }); + mockSendAndConfirm.mockResolvedValue({ signature: 'sig123', fee: 0.000005 }); + (Solana.getInstance as jest.Mock).mockResolvedValue({ + sendAndConfirmTransactionForWallet: mockSendAndConfirm, + getToken: jest.fn().mockImplementation((a: string) => ({ symbol: a === SOL ? 'SOL' : 'USDC', address: a })), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: added }), + }); +}; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { addLiquidityRoute } = await import('../../../../src/connectors/orca/clmm-routes/addLiquidity'); + const { addLiquidityRoute } = await import('../../../../src/trading/trading-clmm-routes/add'); await server.register(addLiquidityRoute); return server; }; @@ -32,25 +104,44 @@ describe('POST /add-liquidity', () => { }); describe('successful liquidity addition', () => { + // The builder applies slippageToleranceBps itself, so handing it the quote's already + // inflated ceiling makes the ceiling the target. Found on a one-sided open, where + // 1 USDC funded deposited 1.009999; an add to an existing position ran the same + // arithmetic, and its own log line has always reported tokenEst*. + it('deposits the quoted estimate, not the slippage ceiling', async () => { + seedConnector(); + + const response = await app.inject({ + method: 'POST', + url: '/add', + payload: { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + walletAddress: mockWalletAddress, + positionAddress: mockPositionAddress, + baseTokenAmount: 1.0, + slippagePct: 1, + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockIncreaseLiquidity).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { tokenMaxA: QUOTE.tokenEstA, tokenMaxB: QUOTE.tokenEstB }, + expect.objectContaining({ slippageToleranceBps: expect.any(Number) }), + ); + }); + it('should add liquidity with base token amount', async () => { - const mockOrca = { - addLiquidity: jest.fn().mockResolvedValue({ - signature: 'sig123', - status: 1, - data: { - baseTokenAmountAdded: 1.0, - quoteTokenAmountAdded: 200, - fee: 0.001, - }, - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + seedConnector(); const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, baseTokenAmount: 1.0, @@ -58,41 +149,45 @@ describe('POST /add-liquidity', () => { }, }); - expect([200, 400, 500]).toContain(response.statusCode); - if (response.statusCode === 200) { - expect(mockOrca.addLiquidity).toHaveBeenCalled(); - } + expect(response.statusCode).toBe(200); + expect(parseWire(response.body)).toMatchObject({ + signature: 'sig123', + status: 1, + data: { poolAddress: POOL, positionAddress: mockPositionAddress, baseTokenAmountAdded: 1 }, + }); + // Only the base side was offered, so the quote is taken from it. + expect(mockQuoteA).toHaveBeenCalled(); }); it('should add liquidity with quote token amount', async () => { - const mockOrca = { - addLiquidity: jest.fn().mockResolvedValue({ - signature: 'sig123', - status: 1, - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + seedConnector(); const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, quoteTokenAmount: 200, }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + // Only the quote side was offered, so the quote is taken from it instead. + expect(mockQuoteB).toHaveBeenCalled(); }); it('should add liquidity with both token amounts', async () => { + seedConnector(); + const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, baseTokenAmount: 1.0, @@ -100,7 +195,10 @@ describe('POST /add-liquidity', () => { }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + // Both offered: the connector prices each side and takes the one that binds. + expect(mockQuoteA).toHaveBeenCalled(); + expect(mockQuoteB).toHaveBeenCalled(); }); }); @@ -108,9 +206,10 @@ describe('POST /add-liquidity', () => { it('should return 400 when positionAddress is missing', async () => { const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, baseTokenAmount: 1.0, }, @@ -122,9 +221,10 @@ describe('POST /add-liquidity', () => { it('should return error when no token amount provided', async () => { const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, }, @@ -136,9 +236,10 @@ describe('POST /add-liquidity', () => { it('should handle invalid position address', async () => { const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: 'invalid', baseTokenAmount: 1.0, @@ -158,9 +259,10 @@ describe('POST /add-liquidity', () => { const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, baseTokenAmount: 1.0, @@ -175,9 +277,10 @@ describe('POST /add-liquidity', () => { const response = await app.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, baseTokenAmount: 1.0, diff --git a/test/connectors/orca/clmm-routes/closePosition.test.ts b/test/connectors/orca/clmm-routes/closePosition.test.ts index 994e334d93..b075d61a0d 100644 --- a/test/connectors/orca/clmm-routes/closePosition.test.ts +++ b/test/connectors/orca/clmm-routes/closePosition.test.ts @@ -38,6 +38,7 @@ describe('closePosition', () => { (Solana.getInstance as jest.Mock).mockResolvedValue({ sendAndConfirmTransactionForWallet: sendForWallet, connection: { getTransaction: jest.fn().mockResolvedValue(null) }, + getConfirmedTransactionData: jest.fn().mockResolvedValue(null), }); const deployment = { programId: PROGRAM, configAddress: POOL }; (Orca.getInstance as jest.Mock).mockResolvedValue({ diff --git a/test/connectors/orca/clmm-routes/collectFees.test.ts b/test/connectors/orca/clmm-routes/collectFees.test.ts index e5fee979e8..09d8a09448 100644 --- a/test/connectors/orca/clmm-routes/collectFees.test.ts +++ b/test/connectors/orca/clmm-routes/collectFees.test.ts @@ -1,21 +1,77 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +// These previously mocked an `orca.collectFees()` the connector never calls, then +// accepted [200, 400, 500] — so every case passed on a 500 from the unmocked SDK and +// asserted nothing. The mocks below are the calls collectFees actually makes, which +// lets each case assert one definite outcome. + +const mockFetchPosition = jest.fn(); +const mockFetchWhirlpool = jest.fn(); +const mockHarvest = jest.fn(); +const mockSendAndConfirm = jest.fn(); jest.mock('../../../../src/chains/solana/solana'); +// The wallet default on the unified schema is read from conf/chains/solana.yml at module +// load. conf/ is gitignored, so a developer machine supplies a real address and CI falls +// back to the template's literal '' — which is not base58, so +// `new PublicKey(...)` throws and the route 500s. These two cases OMIT walletAddress on +// purpose, so they were passing only on machines that happened to have a wallet +// configured. Pin the default here instead of inheriting the ambient one. +jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), + getSolanaChainConfig: () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config').getSolanaChainConfig(), + defaultWallet: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', + }), +})); jest.mock('../../../../src/connectors/orca/orca'); +jest.mock('@orca-so/whirlpools-client', () => ({ + fetchPosition: (...a: any[]) => mockFetchPosition(...a), + fetchWhirlpool: (...a: any[]) => mockFetchWhirlpool(...a), +})); +jest.mock('@orca-so/whirlpools', () => ({ + harvestPositionInstructions: (...a: any[]) => mockHarvest(...a), +})); +jest.mock('../../../../src/connectors/orca/orca.sdk', () => ({ + buildOrcaTransaction: jest.fn().mockReturnValue({ tx: true }), + createOrcaAuthority: jest.fn().mockReturnValue('authority'), +})); + +const WALLET = 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF'; +const POSITION = 'HqoV7Qv27REUtq26uVBhqmaipPC381dj7UceLn433SoH'; +const POOL = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; +const SOL = 'So11111111111111111111111111111111111111112'; +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +/** The Orca and Solana surface collectFees actually touches. */ +const seedConnector = ({ collected = [0.1, 20] as [number, number] } = {}) => { + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {}, deployment: 'mainnet' }); + mockFetchPosition.mockResolvedValue({ data: { whirlpool: POOL, positionMint: 'mint' } }); + mockFetchWhirlpool.mockResolvedValue({ data: { tokenMintA: SOL, tokenMintB: USDC } }); + mockHarvest.mockResolvedValue({ instructions: [], rewardsQuote: { rewards: [] } }); + mockSendAndConfirm.mockResolvedValue({ signature: 'sig123', fee: 0.000005 }); + (Solana.getInstance as jest.Mock).mockResolvedValue({ + sendAndConfirmTransactionForWallet: mockSendAndConfirm, + getToken: jest.fn().mockImplementation((a: string) => ({ symbol: a === SOL ? 'SOL' : 'USDC', address: a })), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: collected }), + }); +}; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { collectFeesRoute } = await import('../../../../src/connectors/orca/clmm-routes/collectFees'); + const { collectFeesRoute } = await import('../../../../src/trading/trading-clmm-routes/collect-fees'); await server.register(collectFeesRoute); return server; }; -describe('POST /collect-fees', () => { - const mockWalletAddress = 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF'; - const mockPositionAddress = 'HqoV7Qv27REUtq26uVBhqmaipPC381dj7UceLn433SoH'; +const collect = (app: any, payload: Record) => + app.inject({ method: 'POST', url: '/collect-fees', payload }); + +describe('POST /collect-fees (orca)', () => { let app: ReturnType; beforeAll(async () => { @@ -23,170 +79,139 @@ describe('POST /collect-fees', () => { await app.ready(); }); - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => jest.clearAllMocks()); afterAll(async () => { await app.close(); }); describe('successful fee collection', () => { - it('should collect fees from position', async () => { - const mockOrca = { - collectFees: jest.fn().mockResolvedValue({ - signature: 'sig123', - status: 1, - data: { - baseTokenAmount: 0.1, - quoteTokenAmount: 20, - fee: 0.001, - }, - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - network: 'mainnet-beta', - walletAddress: mockWalletAddress, - positionAddress: mockPositionAddress, - }, + it('collects fees and reports the amounts and the pool', async () => { + seedConnector(); + + const response = await collect(app, { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + walletAddress: WALLET, + positionAddress: POSITION, + }); + + expect(response.statusCode).toBe(200); + expect(parseWire(response.body)).toMatchObject({ + signature: 'sig123', + status: 1, + data: { poolAddress: POOL, baseFeeAmountCollected: 0.1, quoteFeeAmountCollected: 20 }, + }); + expect(mockHarvest).toHaveBeenCalled(); + }); + + it("falls back to the chain's default network when none is given", async () => { + seedConnector(); + + const response = await collect(app, { + connector: 'orca', + walletAddress: WALLET, + positionAddress: POSITION, }); - expect([200, 400, 500]).toContain(response.statusCode); - if (response.statusCode === 200) { - expect(mockOrca.collectFees).toHaveBeenCalled(); - } + expect(response.statusCode).toBe(200); + // The schema default is solana-mainnet-beta, so the connector is built for it. + expect(Orca.getInstance).toHaveBeenCalledWith('mainnet-beta'); }); - it('should use default network if not provided', async () => { - const mockOrca = { - collectFees: jest.fn().mockResolvedValue({ - signature: 'sig123', - status: 1, - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - walletAddress: mockWalletAddress, - positionAddress: mockPositionAddress, - }, + it('falls back to the configured wallet when none is given', async () => { + seedConnector(); + + const response = await collect(app, { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + positionAddress: POSITION, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + // Whatever the default resolves to, the transaction is sent for it rather than + // for an empty address. + const [, sentFor] = mockSendAndConfirm.mock.calls[0]; + expect(typeof sentFor).toBe('string'); + expect(sentFor.length).toBeGreaterThan(0); }); - it('should use default wallet if not provided', async () => { - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - network: 'mainnet-beta', - positionAddress: mockPositionAddress, - }, + it('reports zeros when the position has no fees to collect', async () => { + seedConnector({ collected: [0, 0] }); + + const response = await collect(app, { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + walletAddress: WALLET, + positionAddress: POSITION, }); - expect([200, 400, 500]).toContain(response.statusCode); + // A no-op collection is a successful collection of nothing, not an error. + expect(response.statusCode).toBe(200); + expect(parseWire(response.body).data).toMatchObject({ + baseFeeAmountCollected: 0, + quoteFeeAmountCollected: 0, + }); }); }); describe('validation', () => { - it('should return 400 when positionAddress is missing', async () => { - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - network: 'mainnet-beta', - walletAddress: mockWalletAddress, - }, + it('rejects a request with no position address', async () => { + const response = await collect(app, { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + walletAddress: WALLET, }); expect(response.statusCode).toBe(400); + expect(mockHarvest).not.toHaveBeenCalled(); }); - it('should handle invalid position address', async () => { - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - network: 'mainnet-beta', - walletAddress: mockWalletAddress, - positionAddress: 'invalid', - }, + it('surfaces an unreadable position rather than reporting a collection', async () => { + seedConnector(); + mockFetchPosition.mockRejectedValue(new Error('Account not found')); + + const response = await collect(app, { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + walletAddress: WALLET, + positionAddress: 'invalid', }); expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(mockSendAndConfirm).not.toHaveBeenCalled(); }); }); describe('error handling', () => { - it('should handle Orca errors gracefully', async () => { - const mockOrca = { - collectFees: jest.fn().mockRejectedValue(new Error('Collect fees failed')), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - network: 'mainnet-beta', - walletAddress: mockWalletAddress, - positionAddress: mockPositionAddress, - }, + it('does not report success when harvesting fails', async () => { + seedConnector(); + mockHarvest.mockRejectedValue(new Error('Collect fees failed')); + + const response = await collect(app, { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + walletAddress: WALLET, + positionAddress: POSITION, }); expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(mockSendAndConfirm).not.toHaveBeenCalled(); }); - it('should handle service unavailable', async () => { + it('does not report success when the connector is unavailable', async () => { + seedConnector(); (Orca.getInstance as jest.Mock).mockResolvedValue(null); - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - network: 'mainnet-beta', - walletAddress: mockWalletAddress, - positionAddress: mockPositionAddress, - }, + const response = await collect(app, { + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + walletAddress: WALLET, + positionAddress: POSITION, }); expect(response.statusCode).toBeGreaterThanOrEqual(400); - }); - - it('should handle when no fees available to collect', async () => { - const mockOrca = { - collectFees: jest.fn().mockResolvedValue({ - signature: 'sig123', - status: 1, - data: { - baseTokenAmount: 0, - quoteTokenAmount: 0, - fee: 0.001, - }, - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - - const response = await app.inject({ - method: 'POST', - url: '/collect-fees', - payload: { - network: 'mainnet-beta', - walletAddress: mockWalletAddress, - positionAddress: mockPositionAddress, - }, - }); - - expect([200, 400, 500]).toContain(response.statusCode); + expect(mockSendAndConfirm).not.toHaveBeenCalled(); }); }); }); diff --git a/test/connectors/orca/clmm-routes/create-pool.test.ts b/test/connectors/orca/clmm-routes/create-pool.test.ts index f027a4383b..8d06fbd81d 100644 --- a/test/connectors/orca/clmm-routes/create-pool.test.ts +++ b/test/connectors/orca/clmm-routes/create-pool.test.ts @@ -20,7 +20,7 @@ const mockWallet = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/orca/clmm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-clmm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -51,11 +51,13 @@ describe('POST /create-pool (Orca CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'SOL', - tickSpacing: 64, + // The unified route names Orca's fee tier binStep; for Orca that IS the tick spacing. + binStep: 64, initialPrice: 150, }, }); @@ -73,7 +75,8 @@ describe('POST /create-pool (Orca CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'USDC', @@ -97,6 +100,7 @@ describe('POST /create-pool (Orca CLMM)', () => { getTransaction: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), }, sendAndConfirmTransactionForWallet: sendAndConfirm, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), }); (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {}, @@ -132,11 +136,13 @@ describe('POST /create-pool (Orca CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'USDC', - tickSpacing: 64, + // The unified route names Orca's fee tier binStep; for Orca that IS the tick spacing. + binStep: 64, initialPrice: 150, }, }); diff --git a/test/connectors/orca/clmm-routes/fetchPools.test.ts b/test/connectors/orca/clmm-routes/fetchPools.test.ts index 75ea87773c..2920c96140 100644 --- a/test/connectors/orca/clmm-routes/fetchPools.test.ts +++ b/test/connectors/orca/clmm-routes/fetchPools.test.ts @@ -1,8 +1,10 @@ import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/connectors/orca/orca'); jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', defaultWallet: '11111111111111111111111111111111', @@ -12,7 +14,7 @@ jest.mock('../../../../src/chains/solana/solana.config', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { fetchPoolsRoute } = await import('../../../../src/connectors/orca/clmm-routes/fetchPools'); + const { fetchPoolsRoute } = await import('../../../../src/trading/trading-clmm-routes/fetchPools'); await server.register(fetchPoolsRoute); return server; }; @@ -98,11 +100,11 @@ describe('GET /fetch-pools (Orca)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=orca', }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('pools'); expect(body).toHaveProperty('total', 2); @@ -136,11 +138,11 @@ describe('GET /fetch-pools (Orca)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&query=SOL-USDC&limit=10', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=orca&query=SOL-USDC&limit=10', }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.pools).toHaveLength(1); expect(body.pools[0].name).toBe('SOL-USDC'); @@ -162,7 +164,7 @@ describe('GET /fetch-pools (Orca)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&sortBy=tvl&sortDirection=desc', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=orca&sortBy=tvl&sortDirection=desc', }); expect(response.statusCode).toBe(200); @@ -182,11 +184,11 @@ describe('GET /fetch-pools (Orca)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=orca', }); expect(response.statusCode).toBe(500); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should handle empty pool results', async () => { @@ -197,11 +199,11 @@ describe('GET /fetch-pools (Orca)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&query=NONEXISTENT', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=orca&query=NONEXISTENT', }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.pools).toHaveLength(0); expect(body.total).toBe(0); }); @@ -214,7 +216,7 @@ describe('GET /fetch-pools (Orca)', () => { const response = await server.inject({ method: 'GET', - url: '/fetch-pools?network=mainnet-beta&verifiedOnly=true', + url: '/fetch-pools?chainNetwork=solana-mainnet-beta&connector=orca&verifiedOnly=true', }); expect(response.statusCode).toBe(200); diff --git a/test/connectors/orca/clmm-routes/openPosition.test.ts b/test/connectors/orca/clmm-routes/openPosition.test.ts index 7e6a147a95..cdf5e48b4c 100644 --- a/test/connectors/orca/clmm-routes/openPosition.test.ts +++ b/test/connectors/orca/clmm-routes/openPosition.test.ts @@ -60,6 +60,7 @@ describe('openPosition', () => { (Solana.getInstance as jest.Mock).mockResolvedValue({ sendAndConfirmTransactionForWallet: sendForWallet, connection: { getTransaction: jest.fn().mockResolvedValue(null) }, + getConfirmedTransactionData: jest.fn().mockResolvedValue(null), }); (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: { getEpochInfo: jest.fn(() => ({ send: jest.fn().mockResolvedValue({ epoch: 1n }) })) }, @@ -129,7 +130,11 @@ describe('openPosition', () => { expect(openPositionInstructionsWithTickBounds).toHaveBeenCalledWith( expect.any(Object), POOL, - { tokenMaxA: 1_000_000_000n, tokenMaxB: 45_000_000n }, + // tokenEst*, not the quote's tokenMax*. The mock deliberately differs between the + // two (900M/40M against 1000M/45M) so this cannot pass on either by accident: + // handing the ceiling to the builder deposits the slippage bound instead of the + // amount the caller asked for. + { tokenMaxA: 900_000_000n, tokenMaxB: 40_000_000n }, 1, 2, expect.objectContaining({ funder: expect.objectContaining({ address: WALLET }) }), diff --git a/test/connectors/orca/clmm-routes/poolInfo.test.ts b/test/connectors/orca/clmm-routes/poolInfo.test.ts index 018cfdfeeb..9b75f3f9a4 100644 --- a/test/connectors/orca/clmm-routes/poolInfo.test.ts +++ b/test/connectors/orca/clmm-routes/poolInfo.test.ts @@ -1,6 +1,7 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/connectors/orca/orca'); jest.mock('../../../../src/chains/solana/solana'); @@ -24,8 +25,8 @@ jest.mock('../../../../src/connectors/orca/orca.utils', () => { const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { poolInfoRoute } = await import('../../../../src/connectors/orca/clmm-routes/poolInfo'); - await server.register(poolInfoRoute); + const { poolsRoute } = await import('../../../../src/trading/clmm/pools'); + await server.register(poolsRoute); return server; }; @@ -116,13 +117,14 @@ describe('GET /pool-info', () => { method: 'GET', url: '/pool-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('address', mockPoolAddress); expect(body).toHaveProperty('baseTokenAddress'); expect(body).toHaveProperty('quoteTokenAddress'); @@ -134,23 +136,78 @@ describe('GET /pool-info', () => { expect(body).toHaveProperty('activeBinId'); }); - it('should return Orca-specific fields', async () => { + // Orca's connector-specific pool fields (liquidity, sqrtPrice, tvlUsdc, ...) are not + // part of the unified /trading/clmm/pool-info response schema, which serializes the + // shared shape. Removing the per-connector route removed the only HTTP surface that + // exposed them, so this asserts the connector still produces them for callers in-process. + it('still produces Orca-specific fields from the connector function', async () => { + const { getPoolInfo } = await import('../../../../src/connectors/orca/clmm-routes/poolInfo'); + const info: any = await getPoolInfo(app, 'mainnet-beta', mockPoolAddress); + + expect(info).toHaveProperty('liquidity'); + expect(info).toHaveProperty('sqrtPrice'); + expect(info).toHaveProperty('tvlUsdc'); + expect(info).toHaveProperty('protocolFeeRate'); + expect(info).toHaveProperty('yieldOverTvl'); + }); + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + + // Mock Orca.getInstance with both getWhirlpool and getPoolInfo + const mockOrca = { + getWhirlpool: jest.fn().mockResolvedValue(mockWhirlpool), + getPoolInfo: jest.fn().mockResolvedValue(mockApiPoolInfo), + solanaKitRpc: {}, // Mock RPC + deployment: { programId: 'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc' }, + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + + // Mock Solana.getInstance + const mockConnection = { + getTokenAccountBalance: jest.fn().mockResolvedValue({ + value: { amount: '1000000000000' }, // 1000 tokens with 9 decimals + }), + }; + const mockSolana = { + connection: mockConnection, + }; + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + // Mock fetchAllMint - returns array of mint data with .data.decimals structure + fetchAllMintMock.mockResolvedValue([ + { data: { decimals: 9 } }, // mintA + { data: { decimals: 9 } }, // mintB + ]); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should return pool information', async () => { const response = await app.inject({ method: 'GET', url: '/pool-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); - expect(body).toHaveProperty('liquidity'); - expect(body).toHaveProperty('sqrtPrice'); - expect(body).toHaveProperty('tvlUsdc'); - expect(body).toHaveProperty('protocolFeeRate'); - expect(body).toHaveProperty('yieldOverTvl'); + const body = parseWire(response.body); + expect(body).toHaveProperty('address', mockPoolAddress); + expect(body).toHaveProperty('baseTokenAddress'); + expect(body).toHaveProperty('quoteTokenAddress'); + expect(body).toHaveProperty('binStep'); + expect(body).toHaveProperty('feePct'); + expect(body).toHaveProperty('price'); + expect(body).toHaveProperty('baseTokenAmount'); + expect(body).toHaveProperty('quoteTokenAmount'); + expect(body).toHaveProperty('activeBinId'); }); it('should return 400 when poolAddress is missing', async () => { @@ -158,7 +215,8 @@ describe('GET /pool-info', () => { method: 'GET', url: '/pool-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', }, }); @@ -176,7 +234,8 @@ describe('GET /pool-info', () => { method: 'GET', url: '/pool-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: 'invalid-pool-address', }, }); @@ -196,7 +255,8 @@ describe('GET /pool-info', () => { method: 'GET', url: '/pool-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, }, }); @@ -209,6 +269,7 @@ describe('GET /pool-info', () => { method: 'GET', url: '/pool-info', query: { + connector: 'orca', poolAddress: mockPoolAddress, }, }); @@ -224,7 +285,8 @@ describe('GET /pool-info', () => { method: 'GET', url: '/pool-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, }, }); @@ -246,10 +308,10 @@ describe('GET /pool-info', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'orca', poolAddress: mockPoolAddress }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.bins).toBeUndefined(); expect(computeOrcaBinDistribution).not.toHaveBeenCalled(); }); @@ -259,10 +321,10 @@ describe('GET /pool-info', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress, binCount: 0 }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'orca', poolAddress: mockPoolAddress, binCount: 0 }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.bins).toBeUndefined(); expect(computeOrcaBinDistribution).not.toHaveBeenCalled(); }); @@ -273,10 +335,10 @@ describe('GET /pool-info', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress, binCount: 11 }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'orca', poolAddress: mockPoolAddress, binCount: 11 }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(Array.isArray(body.bins)).toBe(true); expect(body.bins).toHaveLength(11); expect(body.bins[0]).toEqual( @@ -298,7 +360,7 @@ describe('GET /pool-info', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress, binCount: 999 }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'orca', poolAddress: mockPoolAddress, binCount: 999 }, }); expect(response.statusCode).toBe(400); }); @@ -360,17 +422,18 @@ describe('GET /pool-info', () => { method: 'GET', url: '/pool-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: pyusdPoolAddress, }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('address', pyusdPoolAddress); expect(body).toHaveProperty('baseTokenAddress', pyusdMint); expect(body).toHaveProperty('quoteTokenAddress', usdcMint); - expect(body).toHaveProperty('feePct', 0.01); + expect(Number(body.feePct)).toBe(0.01); expect(body).toHaveProperty('binStep', 1); }); }); diff --git a/test/connectors/orca/clmm-routes/positionInfo.test.ts b/test/connectors/orca/clmm-routes/positionInfo.test.ts index 6ac09ad57b..55916f64f2 100644 --- a/test/connectors/orca/clmm-routes/positionInfo.test.ts +++ b/test/connectors/orca/clmm-routes/positionInfo.test.ts @@ -5,6 +5,8 @@ import { fastifyWithTypeProvider } from '../../../utils/testUtils'; jest.mock('../../../../src/chains/solana/solana', () => ({ Solana: { getInstance: jest.fn(), + // Orca's positionInfo falls back to an example wallet when the caller names none. + getWalletAddressExample: jest.fn().mockResolvedValue('BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF'), }, })); @@ -15,6 +17,7 @@ jest.mock('../../../../src/connectors/orca/orca', () => ({ })); jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', defaultWallet: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', @@ -24,8 +27,8 @@ jest.mock('../../../../src/chains/solana/solana.config', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { positionInfoRoute } = await import('../../../../src/connectors/orca/clmm-routes/positionInfo'); - await server.register(positionInfoRoute); + const { positionsRoute } = await import('../../../../src/trading/clmm/positions'); + await server.register(positionsRoute); return server; }; @@ -69,10 +72,12 @@ describe('GET /position-info', () => { const response = await app.inject({ method: 'GET', url: '/position-info', + // No walletAddress: a position is addressed by its own address and the route + // declares no wallet. It used to be sent here and silently dropped. query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', positionAddress: mockPositionAddress, - walletAddress: mockWalletAddress, }, }); @@ -85,9 +90,23 @@ describe('GET /position-info', () => { }); it('should use default network if not provided', async () => { + // A complete position: the response schema is serialized against it, so a partial + // object fails on the way out rather than telling us anything about defaulting. const mockOrca = { getPositionInfo: jest.fn().mockResolvedValue({ address: mockPositionAddress, + poolAddress: 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + baseTokenAmount: 1.0, + quoteTokenAmount: 200, + baseFeeAmount: 0.01, + quoteFeeAmount: 0.2, + lowerBinId: 1000, + upperBinId: 2000, + lowerPrice: 150, + upperPrice: 250, + price: 200.5, }), }; (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); @@ -96,11 +115,14 @@ describe('GET /position-info', () => { method: 'GET', url: '/position-info', query: { + connector: 'orca', positionAddress: mockPositionAddress, }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + // The schema default is solana-mainnet-beta, so the connector is built for it. + expect(Orca.getInstance).toHaveBeenCalledWith('mainnet-beta'); }); it('should handle null response when position not found', async () => { @@ -113,7 +135,8 @@ describe('GET /position-info', () => { method: 'GET', url: '/position-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', positionAddress: 'invalid-position', }, }); @@ -129,14 +152,15 @@ describe('GET /position-info', () => { method: 'GET', url: '/position-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', }, }); expect(response.statusCode).toBe(400); }); - it('should handle invalid position address', async () => { + it('reports a missing position as not-found rather than as an empty position', async () => { const mockOrca = { getPositionInfo: jest.fn().mockResolvedValue(null), }; @@ -146,13 +170,14 @@ describe('GET /position-info', () => { method: 'GET', url: '/position-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', positionAddress: 'invalid', }, }); - // Route now throws 404 when position not found - expect([404, 400, 500]).toContain(response.statusCode); + // A position the connector cannot read is not-found, not a 200 with nothing in it. + expect(response.statusCode).toBe(404); }); }); @@ -167,7 +192,8 @@ describe('GET /position-info', () => { method: 'GET', url: '/position-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', positionAddress: mockPositionAddress, }, }); @@ -182,7 +208,8 @@ describe('GET /position-info', () => { method: 'GET', url: '/position-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', positionAddress: mockPositionAddress, }, }); @@ -200,7 +227,8 @@ describe('GET /position-info', () => { method: 'GET', url: '/position-info', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', positionAddress: mockPositionAddress, }, }); diff --git a/test/connectors/orca/clmm-routes/positionsOwned.test.ts b/test/connectors/orca/clmm-routes/positionsOwned.test.ts index a2c2a825ec..47e2b9dae0 100644 --- a/test/connectors/orca/clmm-routes/positionsOwned.test.ts +++ b/test/connectors/orca/clmm-routes/positionsOwned.test.ts @@ -5,6 +5,7 @@ import { fastifyWithTypeProvider } from '../../../utils/testUtils'; jest.mock('../../../../src/connectors/orca/orca'); jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', defaultWallet: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', @@ -14,7 +15,7 @@ jest.mock('../../../../src/chains/solana/solana.config', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { positionsOwnedRoute } = await import('../../../../src/connectors/orca/clmm-routes/positionsOwned'); + const { positionsOwnedRoute } = await import('../../../../src/trading/clmm/positions-owned'); await server.register(positionsOwnedRoute); return server; }; @@ -76,7 +77,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, }, }); @@ -105,7 +107,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, }, }); @@ -121,7 +124,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: 'invalid-address', }, }); @@ -139,7 +143,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, }, }); @@ -157,6 +162,7 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { + connector: 'orca', walletAddress: mockWalletAddress, }, }); @@ -175,7 +181,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, }, }); @@ -191,7 +198,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: invalidAddress, }, }); diff --git a/test/connectors/orca/clmm-routes/quotePosition.test.ts b/test/connectors/orca/clmm-routes/quotePosition.test.ts index fb393bafa3..9a897d71e8 100644 --- a/test/connectors/orca/clmm-routes/quotePosition.test.ts +++ b/test/connectors/orca/clmm-routes/quotePosition.test.ts @@ -1,19 +1,38 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +// The connector reaches the SDK through orca.utils.quotePosition, not through a +// method on the Orca instance. Mocking the instance (as this did) left the real helper +// running against an empty rpc, so every success case answered 500 and the assertions +// accepted it. +const mockQuotePosition = jest.fn(); jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/orca/orca'); +jest.mock('../../../../src/connectors/orca/orca.utils', () => ({ + ...jest.requireActual('../../../../src/connectors/orca/orca.utils'), + quotePosition: (...a: any[]) => mockQuotePosition(...a), +})); + +const QUOTE = { + baseLimited: true, + baseTokenAmount: 1, + quoteTokenAmount: 200, + baseTokenAmountMax: 1.01, + quoteTokenAmountMax: 202, +}; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quotePositionRoute } = await import('../../../../src/connectors/orca/clmm-routes/quotePosition'); - await server.register(quotePositionRoute); + const { quoteLiquidityRoute } = await import('../../../../src/trading/clmm/quote-liquidity'); + await server.register(quoteLiquidityRoute); return server; }; -describe('GET /quote-position', () => { +describe('GET /quote-liquidity', () => { const mockPoolAddress = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; let app: ReturnType; @@ -32,24 +51,15 @@ describe('GET /quote-position', () => { describe('successful position quoting', () => { it('should get position quote with base token amount', async () => { - const mockQuote = { - baseTokenAmount: '1.0', - quoteTokenAmount: '200', - liquidity: '1000000', - lowerPrice: '150', - upperPrice: '250', - }; - - const mockOrca = { - quotePosition: jest.fn().mockResolvedValue(mockQuote), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + mockQuotePosition.mockResolvedValue(QUOTE); const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', upperPrice: '250', @@ -57,31 +67,26 @@ describe('GET /quote-position', () => { }, }); - expect([200, 400, 500]).toContain(response.statusCode); - if (response.statusCode === 200) { - const body = JSON.parse(response.body); - expect(body.baseTokenAmount).toBe(1.0); - expect(mockOrca.quotePosition).toHaveBeenCalled(); - } + expect(response.statusCode).toBe(200); + expect(parseWire(response.body)).toMatchObject({ + baseTokenAmount: 1, + quoteTokenAmount: 200, + poolAddress: mockPoolAddress, + }); + // The range and the offered amount reach the quote unchanged. + expect(mockQuotePosition).toHaveBeenCalledWith({}, mockPoolAddress, 150, 250, 1, undefined, 1); }); it('should get position quote with quote token amount', async () => { - const mockQuote = { - baseTokenAmount: '1.0', - quoteTokenAmount: '200', - liquidity: '1000000', - }; - - const mockOrca = { - quotePosition: jest.fn().mockResolvedValue(mockQuote), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + mockQuotePosition.mockResolvedValue(QUOTE); const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', upperPrice: '250', @@ -89,24 +94,21 @@ describe('GET /quote-position', () => { }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + // Only the quote side was offered, so that is what the quote is asked for. + expect(mockQuotePosition).toHaveBeenCalledWith({}, mockPoolAddress, 150, 250, undefined, 200, 1); }); it('should get position quote with both token amounts', async () => { - const mockOrca = { - quotePosition: jest.fn().mockResolvedValue({ - baseTokenAmount: '1.0', - quoteTokenAmount: '200', - liquidity: '1000000', - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + mockQuotePosition.mockResolvedValue(QUOTE); const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', upperPrice: '250', @@ -115,23 +117,21 @@ describe('GET /quote-position', () => { }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + // Both sides offered: the quote decides which one binds. + expect(mockQuotePosition).toHaveBeenCalledWith({}, mockPoolAddress, 150, 250, 1, 200, 1); + expect(parseWire(response.body).baseLimited).toBe(true); }); it('should use default network if not provided', async () => { - const mockOrca = { - quotePosition: jest.fn().mockResolvedValue({ - baseTokenAmount: '1.0', - quoteTokenAmount: '200', - liquidity: '1000000', - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + mockQuotePosition.mockResolvedValue(QUOTE); const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', upperPrice: '250', @@ -139,7 +139,8 @@ describe('GET /quote-position', () => { }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + expect(Orca.getInstance).toHaveBeenCalledWith('mainnet-beta'); }); }); @@ -147,9 +148,10 @@ describe('GET /quote-position', () => { it('should return 400 when poolAddress is missing', async () => { const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', lowerPrice: '150', upperPrice: '250', baseTokenAmount: '1.0', @@ -162,9 +164,10 @@ describe('GET /quote-position', () => { it('should return 400 when lowerPrice is missing', async () => { const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, upperPrice: '250', baseTokenAmount: '1.0', @@ -177,9 +180,10 @@ describe('GET /quote-position', () => { it('should return 400 when upperPrice is missing', async () => { const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', baseTokenAmount: '1.0', @@ -192,9 +196,10 @@ describe('GET /quote-position', () => { it('should return error when no token amount provided', async () => { const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', upperPrice: '250', @@ -207,9 +212,10 @@ describe('GET /quote-position', () => { it('should handle lowerPrice >= upperPrice', async () => { const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '250', upperPrice: '150', @@ -221,11 +227,15 @@ describe('GET /quote-position', () => { }); it('should handle invalid pool address', async () => { + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + mockQuotePosition.mockRejectedValue(new Error('Invalid pool address')); + const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: 'invalid', lowerPrice: '150', upperPrice: '250', @@ -239,16 +249,15 @@ describe('GET /quote-position', () => { describe('error handling', () => { it('should handle Orca errors gracefully', async () => { - const mockOrca = { - quotePosition: jest.fn().mockRejectedValue(new Error('Failed to quote position')), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + mockQuotePosition.mockRejectedValue(new Error('Failed to quote position')); const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', upperPrice: '250', @@ -264,9 +273,10 @@ describe('GET /quote-position', () => { const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: mockPoolAddress, lowerPrice: '150', upperPrice: '250', @@ -278,16 +288,15 @@ describe('GET /quote-position', () => { }); it('should handle pool not found', async () => { - const mockOrca = { - quotePosition: jest.fn().mockRejectedValue(new Error('Pool not found')), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {} }); + mockQuotePosition.mockRejectedValue(new Error('Pool not found')); const response = await app.inject({ method: 'GET', - url: '/quote-position', + url: '/quote-liquidity', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', poolAddress: 'nonexistent123', lowerPrice: '150', upperPrice: '250', diff --git a/test/connectors/orca/clmm-routes/quoteSwap.test.ts b/test/connectors/orca/clmm-routes/quoteSwap.test.ts index e80bd836fe..674f3b24ad 100644 --- a/test/connectors/orca/clmm-routes/quoteSwap.test.ts +++ b/test/connectors/orca/clmm-routes/quoteSwap.test.ts @@ -13,8 +13,8 @@ jest.mock('../../../../src/connectors/orca/orca.utils', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/orca/clmm-routes/quoteSwap'); - await server.register(quoteSwapRoute); + const { makeQuoteSwapRoute } = await import('../../../../src/trading/pool-swap-routes'); + await server.register(makeQuoteSwapRoute('clmm')); return server; }; @@ -62,6 +62,12 @@ describe('GET /quote-swap', () => { // Mock Orca.getInstance const mockOrca = { solanaKitRpc: {}, + // The unified route enters through quoteSwap(), which derives the counter token + // from the pool's mints instead of taking quoteToken from the caller. + getWhirlpool: jest.fn().mockResolvedValue({ + tokenMintA: { toString: () => mockBaseTokenInfo.address }, + tokenMintB: { toString: () => mockQuoteTokenInfo.address }, + }), }; (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); @@ -80,7 +86,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', baseToken: 'SOL', quoteToken: 'USDC', amount: 1.0, @@ -106,7 +113,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', baseToken: 'SOL', quoteToken: 'USDC', amount: 200, @@ -126,7 +134,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', baseToken: 'SOL', quoteToken: 'USDC', amount: 1.0, @@ -161,7 +170,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', baseToken: 'SOL', quoteToken: 'USDC', amount: 1.0, @@ -194,7 +204,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', baseToken: 'SOL', quoteToken: 'UNKNOWN', amount: 1.0, @@ -207,86 +218,50 @@ describe('GET /quote-swap', () => { }); describe('validation', () => { - it('should return 400 when baseToken is missing', async () => { - const response = await app.inject({ - method: 'GET', - url: '/quote-swap', - query: { - network: 'mainnet-beta', - quoteToken: 'USDC', - amount: 1.0, - side: 'SELL', - poolAddress: mockPoolAddress, - }, - }); - - expect(response.statusCode).toBe(400); - }); - - it('should return 400 when quoteToken is missing', async () => { - const response = await app.inject({ - method: 'GET', - url: '/quote-swap', - query: { - network: 'mainnet-beta', - baseToken: 'SOL', - amount: 1.0, - side: 'SELL', - poolAddress: mockPoolAddress, - }, + // The unified schema carries defaults for baseToken/quoteToken/amount/side (the + // convention the unified trading routes already used), so an omitted field is + // filled rather than rejected. What still fails is a token that cannot resolve. + it('rejects a base token that is not one of the pool mints', async () => { + // Re-seeded rather than inherited: the pool-lookup case above swapped the Solana + // mock, and this assertion should not depend on which test ran before it. + (Solana.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn().mockImplementation((symbol: string) => { + if (symbol === 'SOL') return mockBaseTokenInfo; + if (symbol === 'USDC') return mockQuoteTokenInfo; + return null; + }), }); - expect(response.statusCode).toBe(400); - }); - - it('should return 400 when amount is missing', async () => { const response = await app.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', - baseToken: 'SOL', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', + baseToken: 'INVALID', quoteToken: 'USDC', + amount: 1.0, side: 'SELL', poolAddress: mockPoolAddress, }, }); + // The pool's mints are SOL/USDC, so INVALID belongs to neither side of it. expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('not part of pool'); }); - it('should return 400 when side is missing (validated in handler)', async () => { + it('rejects a side outside the enum', async () => { const response = await app.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', baseToken: 'SOL', quoteToken: 'USDC', amount: 1.0, - poolAddress: mockPoolAddress, - }, - }); - - // Side is validated as required in the handler despite schema default - expect(response.statusCode).toBe(400); - }); - - it('should return 400 for invalid token', async () => { - const mockSolana = { - getToken: jest.fn().mockResolvedValue(null), - }; - (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); - - const response = await app.inject({ - method: 'GET', - url: '/quote-swap', - query: { - network: 'mainnet-beta', - baseToken: 'INVALID', - quoteToken: 'USDC', - amount: 1.0, - side: 'SELL', + side: 'SIDEWAYS', poolAddress: mockPoolAddress, }, }); @@ -304,7 +279,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', baseToken: 'SOL', quoteToken: 'USDC', amount: 1.0, diff --git a/test/connectors/orca/clmm-routes/removeLiquidity.test.ts b/test/connectors/orca/clmm-routes/removeLiquidity.test.ts index ffe976fdb2..34ba291b21 100644 --- a/test/connectors/orca/clmm-routes/removeLiquidity.test.ts +++ b/test/connectors/orca/clmm-routes/removeLiquidity.test.ts @@ -2,13 +2,77 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +// This previously mocked an `orca.removeLiquidity()` the connector never calls, then +// accepted [200, 400, 500] — so the success cases passed on a 500 from the unmocked SDK. +// The mocks below are what removeLiquidity actually calls. +const mockFetchPosition = jest.fn(); +const mockFetchWhirlpool = jest.fn(); +const mockFetchAllMint = jest.fn(); +const mockDecreaseLiquidity = jest.fn(); +const mockSendAndConfirm = jest.fn(); + jest.mock('../../../../src/chains/solana/solana'); +// The wallet default on the unified schema is read from conf/chains/solana.yml at module +// load. conf/ is gitignored, so a developer machine supplies a real address and CI falls +// back to the template's literal '' — which is not base58, so +// `new PublicKey(...)` throws and the route 500s. These two cases OMIT walletAddress on +// purpose, so they were passing only on machines that happened to have a wallet +// configured. Pin the default here instead of inheriting the ambient one. +jest.mock('../../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config'), + getSolanaChainConfig: () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana.config').getSolanaChainConfig(), + defaultWallet: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', + }), +})); jest.mock('../../../../src/connectors/orca/orca'); +jest.mock('@orca-so/whirlpools-client', () => ({ + fetchPosition: (...a: any[]) => mockFetchPosition(...a), + fetchWhirlpool: (...a: any[]) => mockFetchWhirlpool(...a), +})); +jest.mock('@orca-so/whirlpools', () => ({ + decreaseLiquidityInstructions: (...a: any[]) => mockDecreaseLiquidity(...a), +})); +jest.mock('@solana-program/token-2022', () => ({ + fetchAllMint: (...a: any[]) => mockFetchAllMint(...a), +})); +jest.mock('../../../../src/connectors/orca/orca.sdk', () => ({ + buildOrcaTransaction: jest.fn().mockReturnValue({ tx: true }), + createOrcaAuthority: jest.fn().mockReturnValue('authority'), +})); + +const POOL = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; +const SOL = 'So11111111111111111111111111111111111111112'; +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +/** The Orca and Solana surface removeLiquidity actually touches. */ +const seedConnector = ({ removed = [1, 200] as [number, number] } = {}) => { + (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {}, deployment: 'mainnet' }); + mockFetchPosition.mockResolvedValue({ + data: { whirlpool: POOL, positionMint: 'mint', liquidity: 1_000_000n }, + }); + mockFetchWhirlpool.mockResolvedValue({ data: { tokenMintA: SOL, tokenMintB: USDC } }); + // token-2022 mints carry an extensions option; the transfer-fee lookup reads it. + mockFetchAllMint.mockResolvedValue([ + { data: { decimals: 9, extensions: { __option: 'None' } } }, + { data: { decimals: 6, extensions: { __option: 'None' } } }, + ]); + mockDecreaseLiquidity.mockResolvedValue({ + instructions: [], + quote: { tokenEstA: 1_000_000_000n, tokenEstB: 200_000_000n }, + }); + mockSendAndConfirm.mockResolvedValue({ signature: 'sig123', fee: 0.000005 }); + (Solana.getInstance as jest.Mock).mockResolvedValue({ + sendAndConfirmTransactionForWallet: mockSendAndConfirm, + getToken: jest.fn().mockImplementation((a: string) => ({ symbol: a === SOL ? 'SOL' : 'USDC', address: a })), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: removed }), + }); +}; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { removeLiquidityRoute } = await import('../../../../src/connectors/orca/clmm-routes/removeLiquidity'); + const { removeLiquidityRoute } = await import('../../../../src/trading/trading-clmm-routes/remove'); await server.register(removeLiquidityRoute); return server; }; @@ -33,70 +97,68 @@ describe('POST /remove-liquidity', () => { describe('successful liquidity removal', () => { it('should remove liquidity with percentage', async () => { - const mockOrca = { - removeLiquidity: jest.fn().mockResolvedValue({ - signature: 'sig123', - status: 1, - data: { - baseTokenAmountRemoved: 1.0, - quoteTokenAmountRemoved: 200, - fee: 0.001, - }, - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + seedConnector(); const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 50, + percentageToRemove: 50, }, }); - expect([200, 400, 500]).toContain(response.statusCode); - if (response.statusCode === 200) { - expect(mockOrca.removeLiquidity).toHaveBeenCalled(); - } + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + signature: 'sig123', + status: 1, + data: { poolAddress: POOL, positionAddress: mockPositionAddress }, + }); + // Half the position's liquidity, as asked. + expect(mockDecreaseLiquidity).toHaveBeenCalledWith({}, 'mint', { liquidity: 500_000n }, expect.anything()); }); it('should remove 100% liquidity', async () => { - const mockOrca = { - removeLiquidity: jest.fn().mockResolvedValue({ - signature: 'sig123', - status: 1, - }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + seedConnector(); const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 100, + percentageToRemove: 100, }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + // 100% takes the whole position, not a rounded-down fraction of it. + expect(mockDecreaseLiquidity).toHaveBeenCalledWith({}, 'mint', { liquidity: 1_000_000n }, expect.anything()); }); it('should use default network and wallet', async () => { + seedConnector(); + const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { + connector: 'orca', positionAddress: mockPositionAddress, - percentage: 25, + percentageToRemove: 25, }, }); - expect([200, 400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(200); + expect(Orca.getInstance).toHaveBeenCalledWith('mainnet-beta'); + const [, sentFor] = mockSendAndConfirm.mock.calls[0]; + expect(typeof sentFor).toBe('string'); + expect(sentFor.length).toBeGreaterThan(0); }); }); @@ -104,40 +166,50 @@ describe('POST /remove-liquidity', () => { it('should return 400 when positionAddress is missing', async () => { const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, - percentage: 50, + percentageToRemove: 50, }, }); expect(response.statusCode).toBe(400); }); - it('should return error when percentage is missing', async () => { + // Omitting percentageToRemove is not an error: the schema defaults it to 100. That + // is a consequential default — the request that says least removes everything — so + // it is pinned rather than assumed. The previous expectation here was an error, + // which only ever passed because the unmocked SDK made every case a 500. + it('removes the whole position when no percentage is given', async () => { + seedConnector(); + const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, }, }); - expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.statusCode).toBe(200); + expect(mockDecreaseLiquidity).toHaveBeenCalledWith({}, 'mint', { liquidity: 1_000_000n }, expect.anything()); }); - it('should handle invalid percentage values', async () => { + it('should handle invalid percentageToRemove values', async () => { const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 150, + percentageToRemove: 150, }, }); @@ -147,19 +219,18 @@ describe('POST /remove-liquidity', () => { describe('error handling', () => { it('should handle Orca errors gracefully', async () => { - const mockOrca = { - removeLiquidity: jest.fn().mockRejectedValue(new Error('Remove liquidity failed')), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); + seedConnector(); + mockDecreaseLiquidity.mockRejectedValue(new Error('Remove liquidity failed')); const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 50, + percentageToRemove: 50, }, }); @@ -171,12 +242,13 @@ describe('POST /remove-liquidity', () => { const response = await app.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'orca', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 50, + percentageToRemove: 50, }, }); diff --git a/test/connectors/orca/orca.routes.test.ts b/test/connectors/orca/orca.routes.test.ts deleted file mode 100644 index e0a992a756..0000000000 --- a/test/connectors/orca/orca.routes.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('Orca Routes Structure', () => { - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should only have clmm-routes folder', () => { - const orcaPath = path.join(__dirname, '../../../src/connectors/orca'); - const clmmRoutesPath = path.join(orcaPath, 'clmm-routes'); - const ammRoutesPath = path.join(orcaPath, 'amm-routes'); - const routerRoutesPath = path.join(orcaPath, 'router-routes'); - const routesPath = path.join(orcaPath, 'routes'); - - expect(fs.existsSync(clmmRoutesPath)).toBe(true); - expect(fs.existsSync(ammRoutesPath)).toBe(false); - expect(fs.existsSync(routerRoutesPath)).toBe(false); - expect(fs.existsSync(routesPath)).toBe(false); - }); - - it('should have swap endpoints within CLMM routes', () => { - const clmmRoutesPath = path.join(__dirname, '../../../src/connectors/orca/clmm-routes'); - const files = fs.readdirSync(clmmRoutesPath); - - expect(files).toContain('executeSwap.ts'); - expect(files).toContain('quoteSwap.ts'); - }); - - it('should have position management endpoints in CLMM routes', () => { - const clmmRoutesPath = path.join(__dirname, '../../../src/connectors/orca/clmm-routes'); - const files = fs.readdirSync(clmmRoutesPath); - - expect(files).toContain('openPosition.ts'); - expect(files).toContain('closePosition.ts'); - expect(files).toContain('addLiquidity.ts'); - expect(files).toContain('removeLiquidity.ts'); - expect(files).toContain('collectFees.ts'); - }); - - it('should have pool and position query endpoints in CLMM routes', () => { - const clmmRoutesPath = path.join(__dirname, '../../../src/connectors/orca/clmm-routes'); - const files = fs.readdirSync(clmmRoutesPath); - - expect(files).toContain('poolInfo.ts'); - expect(files).toContain('positionInfo.ts'); - expect(files).toContain('positionsOwned.ts'); - expect(files).toContain('quotePosition.ts'); - expect(files).toContain('fetchPools.ts'); - }); - }); - - describe('Route Registration', () => { - it('should register Orca CLMM routes at /connectors/orca/clmm', async () => { - // printRoutes compresses shared prefixes (okx/orca), so probe the routes directly: - // a registered route responds with validation/handler errors, an absent one with 404 - const clmmRoute = await fastify.inject({ method: 'GET', url: '/connectors/orca/clmm/pool-info' }); - expect(clmmRoute.statusCode).not.toBe(404); - - // Check that AMM and router routes are NOT registered - const ammRoute = await fastify.inject({ method: 'GET', url: '/connectors/orca/amm/pool-info' }); - expect(ammRoute.statusCode).toBe(404); - - const routerRoute = await fastify.inject({ method: 'GET', url: '/connectors/orca/router/quote-swap' }); - expect(routerRoute.statusCode).toBe(404); - }); - - it('should have key CLMM endpoints', async () => { - const quoteSwap = await fastify.inject({ method: 'GET', url: '/connectors/orca/clmm/quote-swap' }); - expect(quoteSwap.statusCode).not.toBe(404); - - const executeSwap = await fastify.inject({ method: 'POST', url: '/connectors/orca/clmm/execute-swap' }); - expect(executeSwap.statusCode).not.toBe(404); - }); - }); -}); diff --git a/test/connectors/orca/orca.test.ts b/test/connectors/orca/orca.test.ts index 964e01cfa5..bc0ae36a34 100644 --- a/test/connectors/orca/orca.test.ts +++ b/test/connectors/orca/orca.test.ts @@ -17,6 +17,7 @@ jest.mock('@orca-so/whirlpools', () => ({ jest.mock('@orca-so/whirlpools-client', () => ({ fetchWhirlpool: jest.fn(), fetchPosition: jest.fn(), + fetchMaybePosition: jest.fn(), })); jest.mock('@solana/kit', () => ({ address: jest.fn((value: string) => value), @@ -27,7 +28,7 @@ jest.mock('@solana/kit', () => ({ })); import { fetchPositionsForOwner, setNativeMintWrappingStrategy } from '@orca-so/whirlpools'; -import { fetchPosition, fetchWhirlpool } from '@orca-so/whirlpools-client'; +import { fetchMaybePosition, fetchPosition, fetchWhirlpool } from '@orca-so/whirlpools-client'; import { Solana } from '../../../src/chains/solana/solana'; import { Orca } from '../../../src/connectors/orca/orca'; @@ -148,6 +149,7 @@ describe('Orca', () => { it('validates and reads a specific position without a wallet-bound SDK client', async () => { const info = { address: Keypair.generate().publicKey.toBase58() }; + (fetchMaybePosition as jest.Mock).mockResolvedValue({ exists: true }); (getPositionDetails as jest.Mock).mockResolvedValue(info); const orca = await Orca.getInstance('mainnet-beta'); await expect(orca.getPositionInfo(info.address, wallet.publicKey.toBase58())).resolves.toEqual(info); @@ -156,6 +158,27 @@ describe('Orca', () => { ); }); + it('returns null from getPositionInfo only when the account definitively does not exist', async () => { + (fetchMaybePosition as jest.Mock).mockResolvedValue({ exists: false }); + const orca = await Orca.getInstance('mainnet-beta'); + const positionAddress = Keypair.generate().publicKey.toBase58(); + await expect(orca.getPositionInfo(positionAddress, wallet.publicKey.toBase58())).resolves.toBeNull(); + expect(getPositionDetails).not.toHaveBeenCalled(); + }); + + it('propagates transient errors from getPositionInfo instead of reporting the position closed', async () => { + // Callers treat null as "position closed"; a swallowed RPC error here would + // let an LP executor abandon a live, funded position while reporting success. + (fetchMaybePosition as jest.Mock).mockRejectedValue(new Error('429 Too Many Requests')); + const orca = await Orca.getInstance('mainnet-beta'); + const positionAddress = Keypair.generate().publicKey.toBase58(); + await expect(orca.getPositionInfo(positionAddress, wallet.publicKey.toBase58())).rejects.toThrow('429'); + + (fetchMaybePosition as jest.Mock).mockResolvedValue({ exists: true }); + (getPositionDetails as jest.Mock).mockRejectedValue(new Error('RPC node behind')); + await expect(orca.getPositionInfo(positionAddress, wallet.publicKey.toBase58())).rejects.toThrow('RPC node behind'); + }); + it('keeps connector configuration public', async () => { const orca = await Orca.getInstance('mainnet-beta'); expect(orca.config).toBe(OrcaConfig.config); diff --git a/test/connectors/pancakeswap-sol/clmm-routes/collectFees.test.ts b/test/connectors/pancakeswap-sol/clmm-routes/collectFees.test.ts new file mode 100644 index 0000000000..30f1d8230b --- /dev/null +++ b/test/connectors/pancakeswap-sol/clmm-routes/collectFees.test.ts @@ -0,0 +1,159 @@ +import BN from 'bn.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { PancakeswapSol } from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'; +import { buildRemoveLiquidityTransaction } from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.transactions'; +import { transactionFailed } from '../../../../src/services/error-handler'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'); +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.transactions'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { collectFeesRoute } = await import('../../../../src/trading/trading-clmm-routes/collect-fees'); + await server.register(collectFeesRoute); + return server; +}; + +const WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; +const POSITION = 'ARwi1S4DaiTG5DX7S4M4ZsrXqpMD1MrTmbu9ue2tpmEq'; + +const mockSOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const mockUSDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; + +const mockPositionInfo = { + baseTokenAddress: mockSOL.address, + quoteTokenAddress: mockUSDC.address, +}; + +const mockTransaction = { sign: jest.fn() }; + +const baseSolanaMock = (overrides: Record = {}) => ({ + getToken: jest.fn((t: string) => { + if (t === mockSOL.address) return Promise.resolve(mockSOL); + if (t === mockUSDC.address) return Promise.resolve(mockUSDC); + return Promise.resolve(null); + }), + getWallet: jest.fn().mockResolvedValue({ publicKey: WALLET }), + estimateGasPrice: jest.fn().mockResolvedValue(0.001), + simulateWithErrorHandling: jest.fn().mockResolvedValue(undefined), + throwIfLandedWithError: jest.fn().mockResolvedValue(undefined), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [0.0012, 0.34] }), + ...overrides, +}); + +describe('POST /collect-fees (pancakeswap-sol)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (PancakeswapSol.getInstance as jest.Mock).mockResolvedValue({ + getPositionInfo: jest.fn().mockResolvedValue(mockPositionInfo), + }); + (buildRemoveLiquidityTransaction as jest.Mock).mockResolvedValue(mockTransaction); + }); + + it('collects fees via a zero-liquidity decrease — the position liquidity is NOT touched', async () => { + const mockSolana = baseSolanaMock({ + sendAndConfirmRawTransaction: jest + .fn() + .mockResolvedValue({ confirmed: true, signature: 'collect-sig', txData: { meta: { fee: 5000 } } }), + }); + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + const response = await server.inject({ + method: 'POST', + url: '/collect-fees', + body: { + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', + walletAddress: WALLET, + positionAddress: POSITION, + }, + }); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body).toMatchObject({ + signature: 'collect-sig', + status: 1, + data: { + fee: 5000 / 1e9, + baseFeeAmountCollected: 0.0012, + quoteFeeAmountCollected: 0.34, + }, + }); + + // The decrease_liquidity_v2 call must carry liquidity = 0 (fees only) — the old + // implementation removed 1% of the position's liquidity and lied about the fees. + const [, , , liquidityArg, amount0Min, amount1Min] = (buildRemoveLiquidityTransaction as jest.Mock).mock.calls[0]; + expect(liquidityArg).toBeInstanceOf(BN); + expect(liquidityArg.isZero()).toBe(true); + expect(amount0Min.isZero()).toBe(true); + expect(amount1Min.isZero()).toBe(true); + }); + + it('fails loudly when the collect transaction landed on-chain but failed (no silent PENDING)', async () => { + const failedTxData = { meta: { err: { InstructionError: [0, 'Custom'] } } }; + const mockSolana = baseSolanaMock({ + sendAndConfirmRawTransaction: jest + .fn() + .mockResolvedValue({ confirmed: false, signature: 'failed-sig', txData: failedTxData }), + throwIfLandedWithError: jest + .fn() + .mockRejectedValue(transactionFailed('Transaction failed-sig landed on-chain but failed: custom error')), + }); + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + const response = await server.inject({ + method: 'POST', + url: '/collect-fees', + body: { + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', + walletAddress: WALLET, + positionAddress: POSITION, + }, + }); + + expect(response.statusCode).toBe(400); + expect(parseWire(response.body).message).toMatch(/landed on-chain but failed/); + expect(mockSolana.throwIfLandedWithError).toHaveBeenCalledWith('failed-sig', failedTxData); + }); + + it('keeps the pending shape when the transaction genuinely has not landed', async () => { + const mockSolana = baseSolanaMock({ + sendAndConfirmRawTransaction: jest + .fn() + .mockResolvedValue({ confirmed: false, signature: 'pending-sig', txData: null }), + }); + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + const response = await server.inject({ + method: 'POST', + url: '/collect-fees', + body: { + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', + walletAddress: WALLET, + positionAddress: POSITION, + }, + }); + + expect(response.statusCode).toBe(200); + expect(parseWire(response.body)).toMatchObject({ signature: 'pending-sig', status: 0 }); + expect(mockSolana.throwIfLandedWithError).toHaveBeenCalledWith('pending-sig', null); + }); +}); diff --git a/test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts b/test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts index 28fd6fc0e0..7bb91d6b4b 100644 --- a/test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts +++ b/test/connectors/pancakeswap-sol/clmm-routes/create-pool.test.ts @@ -27,7 +27,7 @@ const mockAmmConfig = 'E64NGkDLLCdQ2yFNPcavaKptrEgmiQaNykUuLC1Qgwyp'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-clmm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -62,12 +62,13 @@ describe('POST /create-pool (PancakeSwap Solana CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'SOL', initialPrice: 150, - ammConfig: mockAmmConfig, + ammConfigIndex: 0, }, }); @@ -86,7 +87,8 @@ describe('POST /create-pool (PancakeSwap Solana CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'USDC', diff --git a/test/connectors/pancakeswap-sol/clmm-routes/native-sol-accounting.test.ts b/test/connectors/pancakeswap-sol/clmm-routes/native-sol-accounting.test.ts new file mode 100644 index 0000000000..dd8a12a76c --- /dev/null +++ b/test/connectors/pancakeswap-sol/clmm-routes/native-sol-accounting.test.ts @@ -0,0 +1,329 @@ +import { NATIVE_MINT } from '@solana/spl-token'; +import BN from 'bn.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { + PANCAKESWAP_CLMM_PROGRAM_ID, + PancakeswapSol, +} from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'; +import { buildDecreaseLiquidityV2Instruction } from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.instructions'; +import { + buildTransactionWithInstructions, + buildRemoveLiquidityTransaction, +} from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.transactions'; + +jest.mock('../../../../src/chains/solana/solana'); +// Only the instance is mocked: this module also exports the program id the routes derive +// PDAs from, and an auto-mock turns that into undefined. +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol', () => ({ + ...jest.requireActual('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'), + PancakeswapSol: { getInstance: jest.fn() }, +})); +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.instructions', () => ({ + ...jest.requireActual('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.instructions'), + buildDecreaseLiquidityV2Instruction: jest.fn().mockResolvedValue({ ix: 'decrease' }), + buildClosePositionInstruction: jest.fn().mockResolvedValue({ ix: 'close' }), +})); +// buildUnwrapSolInstructions stays real — it is the fix under test. Only the transaction +// assembly around it is stubbed, so the assertions below are about what really gets built. +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.transactions', () => ({ + ...jest.requireActual('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.transactions'), + buildTransactionWithInstructions: jest.fn().mockResolvedValue({ sign: jest.fn() }), + buildRemoveLiquidityTransaction: jest.fn().mockResolvedValue({ sign: jest.fn() }), +})); +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.parser', () => ({ + ...jest.requireActual('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.parser'), + parsePositionData: jest.fn().mockReturnValue({ + poolId: { toString: () => 'pool' }, + tickLowerIndex: -100, + tickUpperIndex: 100, + liquidity: new BN(1000), + }), +})); + +const WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; +const POSITION = 'ARwi1S4DaiTG5DX7S4M4ZsrXqpMD1MrTmbu9ue2tpmEq'; +const SOL = { symbol: 'SOL', address: NATIVE_MINT.toBase58(), decimals: 9 }; +const USDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; + +// Cy8wiJaS… — the close of the first pancakeswap-sol position ever opened through this +// stack — with the unwrap this file is about applied to it. Lamports as the chain +// reported them, with the WSOL account now closing instead of being left behind. +const NFT_ACCOUNT_RENT = 2_074_080; +const TICK_ARRAY_RENT = 2_846_640; +const POSITION_RENT = 4_231_680; +const WSOL_ACCOUNT_RENT = 2_039_280; +const WSOL_HELD_BEFORE = 719; // dust the wallet already had wrapped +const SOL_FROM_THE_POOL = 8_373_812; // what decrease_liquidity_v2 actually paid out +const USDC_FROM_THE_POOL = 1.033991; +const TX_FEE = 85_000; + +const RENT_REFUNDED = NFT_ACCOUNT_RENT + TICK_ARRAY_RENT + POSITION_RENT + WSOL_ACCOUNT_RENT; + +const closeTxData = { + meta: { + fee: TX_FEE, + preBalances: [ + 2_549_410_306, // the wallet + NFT_ACCOUNT_RENT, + TICK_ARRAY_RENT, + POSITION_RENT, + WSOL_ACCOUNT_RENT + WSOL_HELD_BEFORE, + ], + postBalances: [2_549_410_306 + RENT_REFUNDED + WSOL_HELD_BEFORE + SOL_FROM_THE_POOL - TX_FEE, 0, 0, 0, 0], + preTokenBalances: [ + { accountIndex: 1, mint: POSITION, uiTokenAmount: { amount: '1' } }, + { accountIndex: 4, mint: NATIVE_MINT.toBase58(), uiTokenAmount: { amount: String(WSOL_HELD_BEFORE) } }, + ], + postTokenBalances: [], + }, +}; + +// What extractBalanceChangesAndFee reports for that transaction: the native delta with +// the fee added back, which is rent + dust + the withdrawal all in one number. +const nativeChange = (RENT_REFUNDED + WSOL_HELD_BEFORE + SOL_FROM_THE_POOL) / 1e9; + +// A parsed transaction whose two program instructions are, in order, the fee collect +// and the principal decrease. Position is the whole signal: this program moves fees and +// principal through the same instruction unless they are asked for separately, which is +// why the close now sends a zero-liquidity decrease first. +const parsedCloseTx = (feeBase: number, feeQuote: number, principalBase: number, principalQuote: number) => ({ + transaction: { + message: { + accountKeys: [{ pubkey: 'user-wsol' }, { pubkey: 'user-usdc' }], + instructions: [ + { programId: PANCAKESWAP_CLMM_PROGRAM_ID }, + { programId: PANCAKESWAP_CLMM_PROGRAM_ID }, + { programId: { toString: () => 'ComputeBudget111111111111111111111111111111' } }, + ], + }, + }, + meta: { + preTokenBalances: [ + { accountIndex: 0, mint: SOL.address, uiTokenAmount: { decimals: 9 } }, + { accountIndex: 1, mint: USDC.address, uiTokenAmount: { decimals: 6 } }, + ], + postTokenBalances: [], + innerInstructions: [ + { + index: 0, + instructions: [ + { + parsed: { + type: 'transfer', + info: { amount: String(Math.round(feeBase * 1e9)), source: 'vault', destination: 'user-wsol' }, + }, + }, + { + parsed: { + type: 'transfer', + info: { amount: String(Math.round(feeQuote * 1e6)), source: 'vault', destination: 'user-usdc' }, + }, + }, + ], + }, + { + index: 1, + instructions: [ + { + parsed: { + type: 'transfer', + info: { amount: String(Math.round(principalBase * 1e9)), source: 'vault', destination: 'user-wsol' }, + }, + }, + { + parsed: { + type: 'transfer', + info: { amount: String(Math.round(principalQuote * 1e6)), source: 'vault', destination: 'user-usdc' }, + }, + }, + ], + }, + ], + }, +}); + +const solanaMock = (txData: any, parsedTx: any = { meta: {} }) => ({ + connection: { getAccountInfo: jest.fn().mockResolvedValue({ data: Buffer.alloc(200) }) }, + getToken: jest.fn((t: string) => Promise.resolve(t === SOL.address ? SOL : t === USDC.address ? USDC : null)), + getWallet: jest.fn().mockResolvedValue({ publicKey: WALLET }), + estimateGasPrice: jest.fn().mockResolvedValue(0.001), + simulateWithErrorHandling: jest.fn().mockResolvedValue(undefined), + throwIfLandedWithError: jest.fn().mockResolvedValue(undefined), + sendAndConfirmRawTransaction: jest.fn().mockResolvedValue({ confirmed: true, signature: 'close-sig', txData }), + extractBalanceChangesAndFee: jest + .fn() + .mockResolvedValue({ balanceChanges: [nativeChange, USDC_FROM_THE_POOL], txDetails: parsedTx }), + // The real instruction, stubbed to something identifiable. + unwrapSOL: jest.fn().mockReturnValue({ ix: 'closeWsolAccount' }), +}); + +beforeEach(() => { + jest.clearAllMocks(); + (buildTransactionWithInstructions as jest.Mock).mockResolvedValue({ sign: jest.fn() }); + (buildRemoveLiquidityTransaction as jest.Mock).mockResolvedValue({ sign: jest.fn() }); + (PancakeswapSol.getInstance as jest.Mock).mockResolvedValue({ + getPositionInfo: jest.fn().mockResolvedValue({ + baseTokenAddress: SOL.address, + quoteTokenAddress: USDC.address, + poolAddress: 'pool', + }), + }); +}); + +describe('pancakeswap-sol close: the withdrawal reaches the native balance', () => { + it('unwraps the WSOL the program paid out', async () => { + const solana = solanaMock(closeTxData); + (Solana.getInstance as jest.Mock).mockResolvedValue(solana); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + await closePosition('mainnet-beta', WALLET, POSITION); + + // The close instruction for the wrapped-SOL account has to be last: it can only + // return what the decrease already put there. + const instructions = (buildTransactionWithInstructions as jest.Mock).mock.calls[0][2]; + expect(instructions[instructions.length - 1]).toEqual({ ix: 'closeWsolAccount' }); + expect(solana.unwrapSOL).toHaveBeenCalled(); + }); + + it('leaves a pool with no native side alone', async () => { + const solana = solanaMock(closeTxData); + (Solana.getInstance as jest.Mock).mockResolvedValue(solana); + (PancakeswapSol.getInstance as jest.Mock).mockResolvedValue({ + getPositionInfo: jest.fn().mockResolvedValue({ + baseTokenAddress: USDC.address, + quoteTokenAddress: USDC.address, + poolAddress: 'pool', + }), + }); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + await closePosition('mainnet-beta', WALLET, POSITION); + + expect(solana.unwrapSOL).not.toHaveBeenCalled(); + }); + + it('reports the SOL the pool paid out, not the rent that came back', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POSITION); + + // What the live close reported here was 0.0091524 — the rent, to the lamport, with + // the actual withdrawal appearing in no field at all. + expect(result.data?.baseTokenAmountRemoved).toBeCloseTo(SOL_FROM_THE_POOL / 1e9, 9); + expect(result.data?.baseTokenAmountRemoved).not.toBeCloseTo(RENT_REFUNDED / 1e9, 9); + }); + + it('reports the rent the closed accounts refunded', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POSITION); + + // Was a hardcoded 0 while 0.0111917 SOL came back. + expect(result.data?.positionRentRefunded).toBeCloseTo(RENT_REFUNDED / 1e9, 9); + }); + + it('leaves the non-native side untouched', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POSITION); + + // USDC neither wraps nor carries rent; it was the one number the close got right. + expect(result.data?.quoteTokenAmountRemoved).toBeCloseTo(USDC_FROM_THE_POOL, 9); + }); +}); + +describe('pancakeswap-sol removeLiquidity and collectFees', () => { + it('asks the builder to unwrap the pool mints it withdrew', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { removeLiquidity } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity'); + + await removeLiquidity('mainnet-beta', WALLET, POSITION, 50); + + const poolMints = (buildRemoveLiquidityTransaction as jest.Mock).mock.calls[0][6]; + expect(poolMints).toEqual([SOL.address, USDC.address]); + }); + + it('does not report the reclaimed account rent as liquidity removed', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { removeLiquidity } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity'); + + const result = await removeLiquidity('mainnet-beta', WALLET, POSITION, 100); + + expect(result.data?.baseTokenAmountRemoved).toBeCloseTo(SOL_FROM_THE_POOL / 1e9, 9); + }); + + it('does not report the reclaimed account rent as fee income', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { collectFees } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/collectFees'); + + const result = await collectFees('mainnet-beta', WALLET, POSITION); + + // Rent arriving in the same native change as a fee would be booked as fee income, + // which compounds: hummingbot-api sums these across a position's lifetime. + expect(result.data?.baseFeeAmountCollected).toBeCloseTo(SOL_FROM_THE_POOL / 1e9, 9); + }); +}); + +describe('pancakeswap-sol close: fees and principal are separate money', () => { + // The live close reported `baseFeeAmountCollected: 0` next to a principal that + // contained the fees, because one decrease_liquidity_v2 transfers both. hummingbot-api + // stores the two in different columns, so fee income on this connector was recorded as + // zero forever and returned capital read high. + const FEE_BASE = 0.000123; + const FEE_QUOTE = 0.45; + + it('collects the fees in an instruction of their own, before removing liquidity', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + await closePosition('mainnet-beta', WALLET, POSITION); + + const decreaseCalls = (buildDecreaseLiquidityV2Instruction as jest.Mock).mock.calls; + expect(decreaseCalls).toHaveLength(2); + // First: liquidity 0, which collects fees and touches nothing else. + expect(decreaseCalls[0][3].isZero()).toBe(true); + // Then the principal. + expect(decreaseCalls[1][3].isZero()).toBe(false); + }); + + it('reports what the fee instruction actually paid out', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue( + solanaMock(closeTxData, parsedCloseTx(FEE_BASE, FEE_QUOTE, SOL_FROM_THE_POOL / 1e9, USDC_FROM_THE_POOL)), + ); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POSITION); + + expect(result.data?.baseFeeAmountCollected).toBeCloseTo(FEE_BASE, 9); + expect(result.data?.quoteFeeAmountCollected).toBeCloseTo(FEE_QUOTE, 9); + }); + + it('keeps the fees out of the principal', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue( + solanaMock(closeTxData, parsedCloseTx(FEE_BASE, FEE_QUOTE, SOL_FROM_THE_POOL / 1e9, USDC_FROM_THE_POOL)), + ); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POSITION); + + // The balance change carried both; the principal is what is left after the fees. + expect(result.data?.baseTokenAmountRemoved).toBeCloseTo(SOL_FROM_THE_POOL / 1e9 - FEE_BASE, 9); + expect(result.data?.quoteTokenAmountRemoved).toBeCloseTo(USDC_FROM_THE_POOL - FEE_QUOTE, 9); + }); + + it('leaves the amounts whole when the transaction cannot be read', async () => { + // No parsed inner instructions: report the total under principal and zero fees, + // which is exactly what this route did before. A known shape beats a new silence. + (Solana.getInstance as jest.Mock).mockResolvedValue(solanaMock(closeTxData)); + const { closePosition } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/closePosition'); + + const result = await closePosition('mainnet-beta', WALLET, POSITION); + + expect(result.data?.baseFeeAmountCollected).toBe(0); + expect(result.data?.baseTokenAmountRemoved).toBeCloseTo(SOL_FROM_THE_POOL / 1e9, 9); + }); +}); diff --git a/test/connectors/pancakeswap-sol/clmm-routes/positionsOwned.test.ts b/test/connectors/pancakeswap-sol/clmm-routes/positionsOwned.test.ts index 9f465779cb..77409532e0 100644 --- a/test/connectors/pancakeswap-sol/clmm-routes/positionsOwned.test.ts +++ b/test/connectors/pancakeswap-sol/clmm-routes/positionsOwned.test.ts @@ -10,7 +10,7 @@ jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { positionsOwnedRoute } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/positionsOwned'); + const { positionsOwnedRoute } = await import('../../../../src/trading/clmm/positions-owned'); await server.register(positionsOwnedRoute); return server; }; @@ -122,7 +122,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', walletAddress: mockWalletAddress, }, }); @@ -156,7 +157,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', walletAddress: mockWalletAddress, }, }); @@ -172,7 +174,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', walletAddress: 'invalid-address', }, }); @@ -180,18 +183,6 @@ describe('GET /positions-owned', () => { expect(response.statusCode).toBe(400); }); - it('should return 400 when walletAddress is missing', async () => { - const response = await app.inject({ - method: 'GET', - url: '/positions-owned', - query: { - network: 'mainnet-beta', - }, - }); - - expect(response.statusCode).toBe(400); - }); - it('should skip non-PancakeSwap NFTs', async () => { const mockConnection = { getParsedTokenAccountsByOwner: jest @@ -220,7 +211,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'pancakeswap-sol', walletAddress: mockWalletAddress, }, }); diff --git a/test/connectors/pancakeswap-sol/fees.test.ts b/test/connectors/pancakeswap-sol/fees.test.ts new file mode 100644 index 0000000000..6cedfac8e2 --- /dev/null +++ b/test/connectors/pancakeswap-sol/fees.test.ts @@ -0,0 +1,88 @@ +import { feeGrowthInside, pendingFee, wrappingSub } from '../../../src/connectors/pancakeswap-sol/pancakeswap-sol.fees'; + +const Q64 = 1n << 64n; +const U128 = 1n << 128n; +const perUnit = (n: number) => BigInt(n) * Q64; // fee growth of n tokens per unit of liquidity + +describe('wrappingSub', () => { + it('subtracts normally when it can', () => { + expect(wrappingSub(10n, 4n)).toBe(6n); + }); + + it('wraps rather than going negative', () => { + // Fee-growth accumulators are allowed to overflow, and only the difference between + // two readings means anything. A plain subtraction here would report a position's + // pending fees as roughly 3.4e38 the first time an accumulator lapped. + expect(wrappingSub(4n, 10n)).toBe(U128 - 6n); + }); +}); + +describe('feeGrowthInside', () => { + const lower = { tick: -100, outside0: perUnit(1), outside1: perUnit(2) }; + const upper = { tick: 100, outside0: perUnit(3), outside1: perUnit(4) }; + const global0 = perUnit(10); + const global1 = perUnit(20); + + it('takes both boundaries off the global growth when the price is in range', () => { + const { inside0, inside1 } = feeGrowthInside(0, lower, upper, global0, global1); + + expect(inside0).toBe(perUnit(10 - 1 - 3)); + expect(inside1).toBe(perUnit(20 - 2 - 4)); + }); + + it('flips the lower boundary when the price is below the range', () => { + // Below the lower tick, its stored value is the growth on the far side, so the part + // below the range is everything else. + const { inside0 } = feeGrowthInside(-200, lower, upper, global0, global1); + + // 10 - (10 - 1) - 3 is negative, and that is normal: an "inside" reading is only + // meaningful as the difference between two of them, so the program lets it wrap. + expect(inside0).toBe(wrappingSub(wrappingSub(perUnit(10), perUnit(10) - perUnit(1)), perUnit(3))); + }); + + it('flips the upper boundary when the price is above the range', () => { + const { inside0 } = feeGrowthInside(200, lower, upper, global0, global1); + + expect(inside0).toBe(perUnit(10) - perUnit(1) - (perUnit(10) - perUnit(3))); + }); + + it('treats the lower tick itself as in range', () => { + // The program's comparison is `tick_current >= tick_lower`, and a position at + // exactly its lower tick is in range. + expect(feeGrowthInside(-100, lower, upper, global0, global1).inside0).toBe( + feeGrowthInside(0, lower, upper, global0, global1).inside0, + ); + }); + + it('treats the upper tick itself as out of range', () => { + // `tick_current < tick_upper`: a range is open at the top. + expect(feeGrowthInside(100, lower, upper, global0, global1).inside0).toBe( + feeGrowthInside(200, lower, upper, global0, global1).inside0, + ); + }); +}); + +describe('pendingFee', () => { + it('is what was banked when nothing has accrued since', () => { + expect(pendingFee(1234n, 5_000n, perUnit(7), perUnit(7))).toBe(1234n); + }); + + it('adds the growth since the checkpoint, scaled by liquidity', () => { + // 2 tokens per unit of liquidity, 5000 units, plus 1234 already owed. + expect(pendingFee(1234n, 5_000n, perUnit(9), perUnit(7))).toBe(1234n + 10_000n); + }); + + it('accrues nothing for a position holding no liquidity', () => { + // A just-emptied position is owed exactly what it banked. + expect(pendingFee(1234n, 0n, perUnit(9), perUnit(7))).toBe(1234n); + }); + + it('stays sane across an accumulator wrap', () => { + // The checkpoint was taken just below the u128 ceiling and the accumulator has + // since lapped. The real growth is small; unwrapped arithmetic would report ~3.4e38. + const last = U128 - perUnit(1); + const now = perUnit(1); + + expect(pendingFee(0n, 1n, now, last)).toBe(2n); + }); +}); diff --git a/test/connectors/pancakeswap-sol/open-position-encoding.test.ts b/test/connectors/pancakeswap-sol/open-position-encoding.test.ts new file mode 100644 index 0000000000..526b9c677e --- /dev/null +++ b/test/connectors/pancakeswap-sol/open-position-encoding.test.ts @@ -0,0 +1,53 @@ +import { BorshCoder } from '@coral-xyz/anchor'; +import BN from 'bn.js'; + +const clmmIdl = require('../../../src/connectors/pancakeswap-sol/idl/clmm.json'); + +// GW-28: the first pancakeswap-sol open failed on chain with +// `PriceSlippageCheck Left: 891739 Right: 891740` — one unit of USDC against a 2% +// tolerance that should have allowed seventeen thousand of them. Two defects, both here. + +const coder = new BorshCoder(clmmIdl); +const args = { + tick_lower_index: -100, + tick_upper_index: 100, + tick_array_lower_start_index: -600, + tick_array_upper_start_index: 0, + liquidity: new BN(12345), + amount_0_max: new BN(1000), + amount_1_max: new BN(2000), + with_metadata: true, +}; +const encode = (base_flag: unknown) => + coder.instruction + .encode('open_position_with_token22_nft', { ...args, base_flag }) + .toString('hex') + .slice(-4); + +describe('open_position base_flag encoding', () => { + it('encodes null as None — one byte, not two', () => { + // None is what the route sends: it tells the program to use the liquidity we + // computed and treat the maxes as ceilings. + const none = coder.instruction.encode('open_position_with_token22_nft', { ...args, base_flag: null }); + const some = coder.instruction.encode('open_position_with_token22_nft', { ...args, base_flag: false }); + + expect(none.length).toBe(some.length - 1); + expect(none.toString('hex').slice(-2)).toBe('00'); + }); + + it('encodes false as Some(false), not Some(true)', () => { + expect(encode(false)).toBe('0100'); + expect(encode(true)).toBe('0101'); + }); + + it('encoded `{ some: false }` as Some(TRUE), which is how this shipped', () => { + // Borsh writes an Option as 0x00, or 0x01 followed by the value, and its bool + // layout is `value ? 1 : 0`. An object is truthy, so BOTH branches of + // `baseFlag ? { some: true } : { some: false }` encoded Some(true): every request + // that meant "size from the quote side" told the program to size from the base + // side. This asserts the trap rather than the fix, so nobody reintroduces it + // believing the object form works. + expect(encode({ some: false })).toBe('0101'); + expect(encode({ some: false })).toBe(encode({ some: true })); + }); +}); diff --git a/test/connectors/pancakeswap/amm-routes/create-pool.test.ts b/test/connectors/pancakeswap/amm-routes/create-pool.test.ts index e399b4f467..a13717061b 100644 --- a/test/connectors/pancakeswap/amm-routes/create-pool.test.ts +++ b/test/connectors/pancakeswap/amm-routes/create-pool.test.ts @@ -9,7 +9,7 @@ const mockWallet = '0x0000000000000000000000000000000000000001'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/pancakeswap/amm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-amm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -39,7 +39,8 @@ describe('POST /create-pool (Pancakeswap V2 AMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'bsc', + chainNetwork: 'ethereum-bsc', + connector: 'pancakeswap', walletAddress: mockWallet, baseToken: 'ETH', quoteToken: 'WETH', diff --git a/test/connectors/pancakeswap/amm-routes/quote-swap.test.ts b/test/connectors/pancakeswap/amm-routes/quote-swap.test.ts index cd32f9043d..8fe775e78c 100644 --- a/test/connectors/pancakeswap/amm-routes/quote-swap.test.ts +++ b/test/connectors/pancakeswap/amm-routes/quote-swap.test.ts @@ -4,9 +4,16 @@ import { Address } from 'viem'; import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; import { PancakeswapConfig } from '../../../../src/connectors/pancakeswap/pancakeswap.config'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); jest.mock('../../../../src/connectors/pancakeswap/pancakeswap.config'); +// The unified amm route enters through quoteSwap(), which reads the pair's +// token0/token1 on-chain instead of taking quoteToken from the caller. +jest.mock('../../../../src/connectors/pancakeswap/amm-routes/poolTokens', () => ({ + resolveSwapPair: jest.fn(), + getAmmPoolTokens: jest.fn(), +})); jest.mock('../../../../src/connectors/pancakeswap/pancakeswap'); jest.mock('../../../../src/connectors/pancakeswap/pancakeswap.utils'); @@ -31,8 +38,8 @@ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/pancakeswap/amm-routes/quoteSwap'); - await server.register(quoteSwapRoute); + const { makeQuoteSwapRoute } = await import('../../../../src/trading/pool-swap-routes'); + await server.register(makeQuoteSwapRoute('amm')); return server; }; @@ -108,6 +115,11 @@ describe('GET /quote-swap', () => { ready: jest.fn().mockReturnValue(true), init: jest.fn().mockResolvedValue(undefined), }; + const { resolveSwapPair } = require('../../../../src/connectors/pancakeswap/amm-routes/poolTokens'); + (resolveSwapPair as jest.Mock).mockResolvedValue({ + baseAddress: mockWBNB.address, + quoteAddress: mockUSDC.address, + }); (Ethereum.getInstance as jest.Mock).mockResolvedValue(mockEthereumInstance); (Ethereum.getWalletAddressExample as jest.Mock).mockResolvedValue('0x1234567890123456789012345678901234567890'); @@ -189,7 +201,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', poolAddress: mockPoolAddress, baseToken: 'WBNB', quoteToken: 'USDC', @@ -200,9 +213,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut'); expect(body).toHaveProperty('minAmountOut'); expect(body).toHaveProperty('maxAmountIn', 0.1); @@ -336,7 +349,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', poolAddress: mockPoolAddress, baseToken: 'WBNB', quoteToken: 'USDC', @@ -347,10 +361,10 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); expect(body).toHaveProperty('amountIn'); - expect(body).toHaveProperty('amountOut', 150); + expect(Number(body.amountOut)).toBe(150); expect(body).toHaveProperty('maxAmountIn'); expect(body).toHaveProperty('tokenIn', mockUSDC.address); expect(body).toHaveProperty('tokenOut', mockWBNB.address); @@ -398,11 +412,19 @@ describe('GET /quote-swap', () => { }; (Ethereum.getInstance as jest.Mock).mockResolvedValue(mockEthereumInstance); + // On the unified route the base token is resolved against the pool, so an + // unresolvable token fails there rather than in a caller-supplied quoteToken. + const { resolveSwapPair } = require('../../../../src/connectors/pancakeswap/amm-routes/poolTokens'); + (resolveSwapPair as jest.Mock).mockRejectedValueOnce( + Object.assign(new Error('Token not found: INVALID'), { statusCode: 400 }), + ); + const response = await server.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', poolAddress: mockPoolAddress, baseToken: 'INVALID', quoteToken: 'USDC', @@ -413,6 +435,6 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(400); // Returns 400 for invalid token - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); }); diff --git a/test/connectors/pancakeswap/amm.test.js b/test/connectors/pancakeswap/amm.test.js deleted file mode 100644 index 5f2d693d69..0000000000 --- a/test/connectors/pancakeswap/amm.test.js +++ /dev/null @@ -1,540 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'pancakeswap'; -const PROTOCOL = 'amm'; -const NETWORK = 'base'; // Only test Base network -const BASE_TOKEN = 'WBNB'; -const QUOTE_TOKEN = 'USDC'; -const TEST_POOL = '0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c'; // WBNB-USDC on Base -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - // Use mocks from the same directory - const filePath = path.join(__dirname, 'mocks', `${PROTOCOL}-${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate pool info response structure -function validatePoolInfo(response) { - return ( - response && - typeof response.address === 'string' && - typeof response.baseTokenAddress === 'string' && - typeof response.quoteTokenAddress === 'string' && - typeof response.feePct === 'number' && - typeof response.price === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - response.poolType === 'amm' && - response.lpMint && - typeof response.lpMint.address === 'string' && - typeof response.lpMint.decimals === 'number' - ); -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' - ); -} - -// Tests -describe('Pancakeswap AMM Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Pool Info Endpoint', () => { - test('returns and validates pool info', async () => { - // Load mock response - const mockResponse = loadMockResponse('pool-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePoolInfo(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.address).toBe(TEST_POOL); - expect(response.data.poolType).toBe('amm'); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }), - }), - ); - }); - - test('handles error for non-existent pool', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'Pool not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: 'UNKNOWN', - quoteToken: QUOTE_TOKEN, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'Pool not found', - }, - }, - }); - }); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('quote-swap'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Modify the mock response for BUY direction - const mockSellResponse = loadMockResponse('quote-swap'); - const mockBuyResponse = { - ...mockSellResponse, - // Flip the values for BUY direction - estimatedAmountIn: mockSellResponse.estimatedAmountOut, - estimatedAmountOut: mockSellResponse.estimatedAmountIn, - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.quoteTokenBalanceChange, // Negative for BUY - // For BUY: price = quote needed / base received - price: mockSellResponse.estimatedAmountOut / mockSellResponse.estimatedAmountIn, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Mock a quote-swap response to use as input for execute-swap - const quoteResponse = loadMockResponse('quote-swap'); - - // Mock a successful execution response - const executeResponse = { - signature: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - amountIn: quoteResponse.estimatedAmountIn, - amountOut: quoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: quoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: quoteResponse.quoteTokenBalanceChange, - }; - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(quoteResponse.estimatedAmountIn); - expect(response.data.amountOut).toBe(quoteResponse.estimatedAmountOut); - }); - - test('handles transaction simulation error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 500, - data: { - error: 'InternalServerError', - message: 'Transaction simulation failed', - code: 500, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1000000.0, - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 500, - data: { - error: 'InternalServerError', - }, - }, - }); - }); - }); - - describe('Quote Liquidity Endpoint', () => { - test('returns and validates liquidity quote', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - baseTokenLiquidity: 1.0, - quoteTokenLiquidity: 2340.5, - lpTokenAmount: 54.32, - shareOfPool: 0.0001, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-liquidity`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.lpTokenAmount).toBeGreaterThan(0); - expect(response.data.shareOfPool).toBeGreaterThan(0); - }); - - test('handles imbalanced liquidity error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Token amounts do not match pool ratio', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-liquidity`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 100.0, // Wrong ratio - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('ratio'), - }, - }, - }); - }); - }); - - describe('Position Info Endpoint', () => { - test('returns and validates position info', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - positionId: 'pancakeswap-v2-lp-123456', - lpTokenAmount: 100.5, - baseTokenAmount: 1.85, - quoteTokenAmount: 4329.225, - shareOfPool: 0.01, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 100.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.lpTokenAmount).toBe(100.5); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - }); - }); - - describe('Add Liquidity Endpoint', () => { - test('returns successful liquidity addition', async () => { - const mockResponse = { - signature: '0xabcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab', - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - lpTokenAmount: 54.32, - poolAddress: TEST_POOL, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.lpTokenAmount).toBeGreaterThan(0); - expect(response.data.baseTokenAmount).toBe(1.0); - expect(response.data.quoteTokenAmount).toBe(2340.5); - }); - - test('handles insufficient balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient balance for WBNB', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 10000.0, // Large amount - quoteTokenAmount: 23405000.0, - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient balance'), - }, - }, - }); - }); - }); - - describe('Remove Liquidity Endpoint', () => { - test('returns successful liquidity removal', async () => { - const mockResponse = { - signature: '0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcd12', - baseTokenAmount: 0.95, - quoteTokenAmount: 2223.475, - lpTokenAmount: 54.32, - poolAddress: TEST_POOL, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 54.32, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - expect(response.data.lpTokenAmount).toBe(54.32); - }); - - test('handles insufficient LP token balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient LP token balance', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 10000.0, // Large amount - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient LP token balance'), - }, - }, - }); - }); - }); -}); diff --git a/test/connectors/pancakeswap/clmm-routes/create-pool.test.ts b/test/connectors/pancakeswap/clmm-routes/create-pool.test.ts index 9cd3a8d8e3..9475457b71 100644 --- a/test/connectors/pancakeswap/clmm-routes/create-pool.test.ts +++ b/test/connectors/pancakeswap/clmm-routes/create-pool.test.ts @@ -10,7 +10,7 @@ const mockWallet = '0x0000000000000000000000000000000000000001'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/pancakeswap/clmm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-clmm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -45,16 +45,19 @@ describe('POST /create-pool (Pancakeswap V3 CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'bsc', + chainNetwork: 'ethereum-bsc', + connector: 'pancakeswap', walletAddress: mockWallet, baseToken: 'WBNB', quoteToken: 'USDT', - fee: 3000, // valid on Uniswap V3, but NOT one of Pancakeswap's 100 / 500 / 2500 / 10000 + // The unified route takes the tier as feeBps and multiplies by 100. 30 bps -> + // 3000, valid on Uniswap V3 but not one of Pancakeswap's 100 / 500 / 2500 / 10000. + feeBps: 30, initialPrice: 600, }, }); - // Fastify schema validation rejects the out-of-enum fee before the handler runs → 400. + // The connector rejects a tier it does not support with a 400. expect(response.statusCode).toBe(400); }); @@ -63,11 +66,14 @@ describe('POST /create-pool (Pancakeswap V3 CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'bsc', + chainNetwork: 'ethereum-bsc', + // The unified route requires the V3 fee tier explicitly. + // PancakeSwap V3 tiers are 1 / 5 / 25 / 100 bps. + feeBps: 25, + connector: 'pancakeswap', walletAddress: mockWallet, baseToken: 'WBNB', quoteToken: 'WBNB', - fee: 2500, initialPrice: 600, }, }); diff --git a/test/connectors/pancakeswap/clmm-routes/pool-info.test.ts b/test/connectors/pancakeswap/clmm-routes/pool-info.test.ts new file mode 100644 index 0000000000..c77df909bb --- /dev/null +++ b/test/connectors/pancakeswap/clmm-routes/pool-info.test.ts @@ -0,0 +1,175 @@ +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/pancakeswap/pancakeswap'); +jest.mock('../../../../src/connectors/pancakeswap/pancakeswap.utils'); +jest.mock('../../../../src/connectors/clmm-v3-utils'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { poolsRoute } = await import('../../../../src/trading/clmm/pools'); + await server.register(poolsRoute); + return server; +}; + +// PancakeSwap V3 USDT-WBNB pool on BSC. +const POOL_ADDRESS = '0x172fcd41e0913e95784454622d1c3724f546f849'; +const USDT = { address: '0x55d398326f99059fF775485246999027B3197955', symbol: 'USDT', decimals: 18 }; +const WBNB = { address: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', symbol: 'WBNB', decimals: 18 }; + +const USDT_RAW_BALANCE = BigNumber.from('4200000000000000000000000'); // 4,200,000 * 1e18 +const WBNB_RAW_BALANCE = BigNumber.from('7000000000000000000000'); // 7,000 * 1e18 + +// V3 virtual liquidity — the value the route used to report for BOTH tokens. +const POOL_LIQUIDITY = BigNumber.from('11034936417288527'); + +const mockPool = { + token0: { address: USDT.address, decimals: USDT.decimals }, + token1: { address: WBNB.address, decimals: WBNB.decimals }, + liquidity: POOL_LIQUIDITY, + sqrtRatioX96: BigNumber.from('79228162514264337593543950336'), + token0Price: { toSignificant: () => '0.00166' }, // USDT priced in WBNB + token1Price: { toSignificant: () => '602.4' }, + fee: 2500, // 0.25% in hundredths-of-bips + tickSpacing: 50, + tickCurrent: -64000, +}; + +const setupMocks = async () => { + const { Pancakeswap } = await import('../../../../src/connectors/pancakeswap/pancakeswap'); + const { getPancakeswapPoolInfo, formatTokenAmount } = await import( + '../../../../src/connectors/pancakeswap/pancakeswap.utils' + ); + + (getPancakeswapPoolInfo as jest.Mock).mockResolvedValue({ + baseTokenAddress: USDT.address, + quoteTokenAddress: WBNB.address, + poolType: 'clmm', + }); + (formatTokenAmount as jest.Mock).mockImplementation( + (amount: string, decimals: number) => Number(amount) / Math.pow(10, decimals), + ); + + (Pancakeswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn().mockImplementation((addr: string) => { + if (addr.toLowerCase() === USDT.address.toLowerCase()) return USDT; + if (addr.toLowerCase() === WBNB.address.toLowerCase()) return WBNB; + return null; + }), + getV3Pool: jest.fn().mockResolvedValue(mockPool), + }); + + const mockUsdtContract = { address: USDT.address }; + const mockWbnbContract = { address: WBNB.address }; + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + provider: { _isProvider: true }, + getContract: jest.fn().mockImplementation((tokenAddress: string) => { + if (tokenAddress.toLowerCase() === USDT.address.toLowerCase()) return mockUsdtContract; + if (tokenAddress.toLowerCase() === WBNB.address.toLowerCase()) return mockWbnbContract; + throw new Error(`unexpected contract address ${tokenAddress}`); + }), + getERC20BalanceByAddress: jest.fn().mockImplementation((contract: any, address: string, decimals: number) => { + expect(address).toBe(POOL_ADDRESS); // route must query the pool contract + if (contract.address === USDT.address) return Promise.resolve({ value: USDT_RAW_BALANCE, decimals }); + if (contract.address === WBNB.address) return Promise.resolve({ value: WBNB_RAW_BALANCE, decimals }); + return Promise.reject(new Error('unexpected token')); + }), + }); +}; + +const sampleBins = (n: number) => + Array.from({ length: n }, (_, i) => ({ + binId: -64000 + i * 50, + price: 600 + i, + baseTokenAmount: 10 + i, + quoteTokenAmount: 20 + i, + })); + +describe('GET /pool-info (PancakeSwap CLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await setupMocks(); + }); + + it("returns the pool contract's actual ERC20 balances, not pool.liquidity", async () => { + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { chainNetwork: 'ethereum-bsc', connector: 'pancakeswap', poolAddress: POOL_ADDRESS }, + }); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + + expect(body.address).toBe(POOL_ADDRESS); + expect(Number(body.feePct)).toBeCloseTo(0.25, 6); + expect(body.binStep).toBe(50); + expect(body.activeBinId).toBe(-64000); + + // The fix: token amounts come from ERC20 balanceOf, not virtual liquidity. + expect(body.baseTokenAmount).toBeCloseTo(4200000, 0); + expect(body.quoteTokenAmount).toBeCloseTo(7000, 0); + + // Regression guard: the legacy bug reported pool.liquidity / 10^decimals for + // both sides — identical figures that mean nothing in token terms. + const buggy = Number(POOL_LIQUIDITY.toString()) / 1e18; + expect(body.baseTokenAmount).not.toBeCloseTo(buggy, 6); + expect(body.quoteTokenAmount).not.toBeCloseTo(buggy, 6); + expect(body.baseTokenAmount).not.toEqual(body.quoteTokenAmount); + }); + + it('omits bins and skips the tick reads when binCount is absent', async () => { + const { computeV3BinDistribution } = await import('../../../../src/connectors/clmm-v3-utils'); + + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { chainNetwork: 'ethereum-bsc', connector: 'pancakeswap', poolAddress: POOL_ADDRESS }, + }); + + expect(response.statusCode).toBe(200); + expect(parseWire(response.body).bins).toBeUndefined(); + expect(computeV3BinDistribution).not.toHaveBeenCalled(); + }); + + it('returns bins when binCount > 0', async () => { + const { computeV3BinDistribution } = await import('../../../../src/connectors/clmm-v3-utils'); + (computeV3BinDistribution as jest.Mock).mockResolvedValueOnce(sampleBins(11)); + + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { chainNetwork: 'ethereum-bsc', connector: 'pancakeswap', poolAddress: POOL_ADDRESS, binCount: '11' }, + }); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body.bins).toHaveLength(11); + expect(body.bins[0]).toEqual({ binId: -64000, price: 600, baseTokenAmount: 10, quoteTokenAmount: 20 }); + + expect(computeV3BinDistribution).toHaveBeenCalledTimes(1); + const args = (computeV3BinDistribution as jest.Mock).mock.calls[0][0]; + expect(args.binCount).toBe(11); + expect(args.tickSpacing).toBe(50); + expect(args.currentTick).toBe(-64000); + expect(args.isBaseToken0).toBe(true); + // PancakeSwap's SDK math must be supplied — the shared helper is math-agnostic. + expect(typeof args.tickMath.getSqrtRatioAtTick).toBe('function'); + expect(typeof args.sqrtPriceMath.getAmount0Delta).toBe('function'); + }); +}); diff --git a/test/connectors/pancakeswap/clmm.test.js b/test/connectors/pancakeswap/clmm.test.js deleted file mode 100644 index f58badf74a..0000000000 --- a/test/connectors/pancakeswap/clmm.test.js +++ /dev/null @@ -1,752 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'pancakeswap'; -const PROTOCOL = 'clmm'; -const NETWORK = 'base'; // Only test Base network -const BASE_TOKEN = 'WBNB'; -const QUOTE_TOKEN = 'USDC'; -const TEST_POOL = '0xd0b53d9277642d899df5c87a3966a349a798f224'; // WBNB-USDC on Base -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - // Use mocks from the same directory - const filePath = path.join(__dirname, 'mocks', `${PROTOCOL}-${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate pool info response structure -function validatePoolInfo(response) { - return ( - response && - typeof response.address === 'string' && - typeof response.baseTokenAddress === 'string' && - typeof response.quoteTokenAddress === 'string' && - typeof response.feePct === 'number' && - typeof response.price === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' - ); -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' && - typeof response.computeUnits === 'number' // Added computeUnits - ); -} - -// Function to validate position info response structure -function validatePositionInfo(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.positionId === 'string' && - typeof response.lowerTick === 'number' && - typeof response.upperTick === 'number' && - typeof response.liquidity === 'string' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.unclaimedFeeBaseAmount === 'number' && - typeof response.unclaimedFeeQuoteAmount === 'number' - ); -} - -// Function to validate quote position response -function validateQuotePosition(response) { - return ( - response && - typeof response.baseLimited === 'boolean' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.baseTokenAmountMax === 'number' && - typeof response.quoteTokenAmountMax === 'number' && - response.liquidity !== undefined && // Can be string or object - typeof response.computeUnits === 'number' // Added computeUnits - ); -} - -// Function to validate open position response -function validateOpenPosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.positionId === 'string' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate add liquidity response -function validateAddLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate remove liquidity response -function validateRemoveLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number')) - ); -} - -// Function to validate close position response -function validateClosePosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number' && - typeof response.data.baseFeeAmountCollected === 'number' && - typeof response.data.quoteFeeAmountCollected === 'number')) - ); -} - -// Tests -describe('Pancakeswap CLMM Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Pool Info Endpoint', () => { - test('returns and validates pool info', async () => { - // Load mock response - const mockResponse = loadMockResponse('pool-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePoolInfo(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.address).toBe(TEST_POOL); - expect(response.data.feePct).toBe(0.05); // 0.05% fee for CLMM - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }), - }), - ); - }); - - test('handles error for non-existent pool', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'Pool not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: 'UNKNOWN', - quoteToken: QUOTE_TOKEN, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'Pool not found', - }, - }, - }); - }); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('quote-swap'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Modify the mock response for BUY direction - const mockSellResponse = loadMockResponse('quote-swap'); - const mockBuyResponse = { - ...mockSellResponse, - // Flip the values for BUY direction - estimatedAmountIn: mockSellResponse.estimatedAmountOut, - estimatedAmountOut: mockSellResponse.estimatedAmountIn, - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.quoteTokenBalanceChange, // Negative for BUY - // For BUY: price = quote needed / base received - price: mockSellResponse.estimatedAmountOut / mockSellResponse.estimatedAmountIn, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Mock a quote-swap response to use as input for execute-swap - const quoteResponse = loadMockResponse('quote-swap'); - - // Mock a successful execution response - const executeResponse = { - signature: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - amountIn: quoteResponse.estimatedAmountIn, - amountOut: quoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: quoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: quoteResponse.quoteTokenBalanceChange, - }; - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(quoteResponse.estimatedAmountIn); - expect(response.data.amountOut).toBe(quoteResponse.estimatedAmountOut); - }); - }); - - describe('Position Info Endpoint', () => { - test('returns and validates position info', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - positionId: '123456', - lowerTick: -887272, - upperTick: 887272, - liquidity: '1000000000000000000', - baseTokenAmount: 1.5, - quoteTokenAmount: 3510.75, - unclaimedFeeBaseAmount: 0.001, - unclaimedFeeQuoteAmount: 2.34, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: '123456', - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.positionId).toBe('123456'); - expect(response.data.liquidity).toBeDefined(); - expect(response.data.unclaimedFeeBaseAmount).toBeGreaterThanOrEqual(0); - expect(response.data.unclaimedFeeQuoteAmount).toBeGreaterThanOrEqual(0); - }); - - test('handles position not found error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Position not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: 'invalid-position', - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Positions Owned Endpoint', () => { - test('returns list of owned positions', async () => { - const mockResponse = [ - { - poolAddress: TEST_POOL, - positionId: '123456', - lowerTick: -887272, - upperTick: 887272, - liquidity: '1000000000000000000', - baseTokenAmount: 1.5, - quoteTokenAmount: 3510.75, - unclaimedFeeBaseAmount: 0.001, - unclaimedFeeQuoteAmount: 2.34, - }, - { - poolAddress: TEST_POOL, - positionId: '789012', - lowerTick: -443636, - upperTick: 443636, - liquidity: '500000000000000000', - baseTokenAmount: 0.75, - quoteTokenAmount: 1755.375, - unclaimedFeeBaseAmount: 0.0005, - unclaimedFeeQuoteAmount: 1.17, - }, - ]; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/positions-owned`, { - params: { - network: NETWORK, - wallet: TEST_WALLET, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(Array.isArray(response.data)).toBe(true); - expect(response.data.length).toBe(2); - expect(response.data[0].positionId).toBe('123456'); - expect(response.data[1].positionId).toBe('789012'); - }); - }); - - describe('Quote Position Endpoint', () => { - test('returns and validates quote for new position', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - liquidity: '680000000000000000', - shareOfPool: 0.0001, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.liquidity).toBeDefined(); - expect(response.data.shareOfPool).toBeGreaterThan(0); - }); - - test('handles invalid tick range error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Invalid tick range', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: 100, - upperTick: 50, // Invalid: upper < lower - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Invalid tick range'), - }, - }, - }); - }); - }); - - describe('Open Position Endpoint', () => { - test('returns successful position opening', async () => { - const mockResponse = { - signature: '0xabcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab', - positionId: '345678', - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - liquidity: '680000000000000000', - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBeDefined(); - expect(response.data.liquidity).toBeDefined(); - }); - - test('handles insufficient balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient balance for WBNB', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 10000.0, // Large amount - quoteTokenAmount: 23405000.0, - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient balance'), - }, - }, - }); - }); - }); - - describe('Add Liquidity Endpoint', () => { - test('returns successful liquidity addition to existing position', async () => { - const mockResponse = { - signature: '0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcd12', - positionId: '123456', - liquidity: '340000000000000000', - baseTokenAmount: 0.5, - quoteTokenAmount: 1170.25, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - positionId: '123456', - baseTokenAmount: 0.5, - quoteTokenAmount: 1170.25, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe('123456'); - expect(response.data.liquidity).toBeDefined(); - }); - }); - - describe('Remove Liquidity Endpoint', () => { - test('returns successful liquidity removal', async () => { - const mockResponse = { - signature: '0x1234abcd5678efgh1234abcd5678efgh1234abcd5678efgh1234abcd5678efgh', - positionId: '123456', - baseTokenAmount: 0.75, - quoteTokenAmount: 1755.375, - liquidityRemoved: '500000000000000000', - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - positionId: '123456', - liquidity: '500000000000000000', - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - expect(response.data.liquidityRemoved).toBe('500000000000000000'); - }); - }); - - describe('Close Position Endpoint', () => { - test('returns successful position closure', async () => { - const mockResponse = { - signature: '0xaaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888', - positionId: '123456', - baseTokenAmount: 1.5, - quoteTokenAmount: 3510.75, - feeBaseAmount: 0.001, - feeQuoteAmount: 2.34, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/close-position`, { - network: NETWORK, - positionId: '123456', - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe('123456'); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - }); - }); - - describe('Collect Fees Endpoint', () => { - test('returns successful fee collection', async () => { - const mockResponse = { - signature: '0x9999888877776666555544443333222211110000aaaabbbbccccddddeeeeffff', - positionId: '123456', - feeBaseAmount: 0.001, - feeQuoteAmount: 2.34, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/collect-fees`, { - network: NETWORK, - positionId: '123456', - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.feeBaseAmount).toBeGreaterThanOrEqual(0); - expect(response.data.feeQuoteAmount).toBeGreaterThanOrEqual(0); - }); - }); -}); diff --git a/test/connectors/pancakeswap/mocks/amm-pool-info-invalid.json b/test/connectors/pancakeswap/mocks/amm-pool-info-invalid.json deleted file mode 100644 index 12b4c977de..0000000000 --- a/test/connectors/pancakeswap/mocks/amm-pool-info-invalid.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "Internal Server Error", - "message": "An unexpected error occurred" -} diff --git a/test/connectors/pancakeswap/mocks/amm-pool-info.json b/test/connectors/pancakeswap/mocks/amm-pool-info.json deleted file mode 100644 index c261ed4019..0000000000 --- a/test/connectors/pancakeswap/mocks/amm-pool-info.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "address": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "baseTokenAddress": "0x4200000000000000000000000000000000000006", - "quoteTokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "feePct": 0.3, - "price": 2200.5, - "baseTokenAmount": 80.5, - "quoteTokenAmount": 177150.25, - "poolType": "amm", - "lpMint": { - "address": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "decimals": 18 - } -} diff --git a/test/connectors/pancakeswap/mocks/amm-quote-liquidity-imbalanced.json b/test/connectors/pancakeswap/mocks/amm-quote-liquidity-imbalanced.json deleted file mode 100644 index 3be9c3a5ff..0000000000 --- a/test/connectors/pancakeswap/mocks/amm-quote-liquidity-imbalanced.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "InternalServerError", - "message": "Failed to get liquidity quote" -} diff --git a/test/connectors/pancakeswap/mocks/amm-quote-swap-invalid-token.json b/test/connectors/pancakeswap/mocks/amm-quote-swap-invalid-token.json deleted file mode 100644 index 4f7848f680..0000000000 --- a/test/connectors/pancakeswap/mocks/amm-quote-swap-invalid-token.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 400, - "error": "BadRequestError", - "message": "Base token not found: INVALID" -} diff --git a/test/connectors/pancakeswap/mocks/amm-quote-swap-sell.json b/test/connectors/pancakeswap/mocks/amm-quote-swap-sell.json deleted file mode 100644 index 284aa098b7..0000000000 --- a/test/connectors/pancakeswap/mocks/amm-quote-swap-sell.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "poolAddress": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "estimatedAmountIn": 0.001, - "estimatedAmountOut": 2.595146, - "minAmountOut": 2.54426, - "maxAmountIn": 0.001, - "baseTokenBalanceChange": -0.001, - "quoteTokenBalanceChange": 2.595146, - "price": 2595.146, - "gasPrice": 0.001860001, - "gasLimit": 300000, - "gasCost": 5.580003e-7 -} diff --git a/test/connectors/pancakeswap/mocks/amm-quote-swap.json b/test/connectors/pancakeswap/mocks/amm-quote-swap.json deleted file mode 100644 index cdbce00fc7..0000000000 --- a/test/connectors/pancakeswap/mocks/amm-quote-swap.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 2200.5, - "minAmountOut": 2178.5, - "maxAmountIn": 1.01, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 2200.5, - "price": 2200.5, - "computeUnits": 250000 -} diff --git a/test/connectors/pancakeswap/mocks/clmm-pool-info.json b/test/connectors/pancakeswap/mocks/clmm-pool-info.json deleted file mode 100644 index 75500ce4a1..0000000000 --- a/test/connectors/pancakeswap/mocks/clmm-pool-info.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "address": "0xd0b53d9277642d899df5c87a3966a349a798f224", - "baseTokenAddress": "0x4200000000000000000000000000000000000006", - "quoteTokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "feePct": 0.05, - "price": 2202.75, - "baseTokenAmount": 150.25, - "quoteTokenAmount": 330963.1, - "tickSpacing": 10, - "tick": -202315 -} diff --git a/test/connectors/pancakeswap/mocks/clmm-quote-swap.json b/test/connectors/pancakeswap/mocks/clmm-quote-swap.json deleted file mode 100644 index 2edd5f32b6..0000000000 --- a/test/connectors/pancakeswap/mocks/clmm-quote-swap.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "poolAddress": "0xd0b53d9277642d899df5c87a3966a349a798f224", - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 2202.75, - "minAmountOut": 2180.72, - "maxAmountIn": 1.01, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 2202.75, - "price": 2202.75, - "routePath": "WBNB → USDC", - "computeUnits": 180000 -} diff --git a/test/connectors/pancakeswap/mocks/execute-swap.json b/test/connectors/pancakeswap/mocks/execute-swap.json deleted file mode 100644 index ea73ca32d5..0000000000 --- a/test/connectors/pancakeswap/mocks/execute-swap.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "signature": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "data": { - "tokenIn": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "tokenOut": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "amountIn": 1.0, - "amountOut": 1800.0, - "fee": 0.003, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 1800.0 - } -} diff --git a/test/connectors/pancakeswap/mocks/quote-swap.json b/test/connectors/pancakeswap/mocks/quote-swap.json deleted file mode 100644 index 7dbcabfc81..0000000000 --- a/test/connectors/pancakeswap/mocks/quote-swap.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 1800.0, - "minAmountOut": 1782.0, - "maxAmountIn": 1.0, - "price": 1800.0, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 1800.0, - "computeUnits": 250000, - "gasPrice": 50000000000, - "gasLimit": 300000, - "gasCost": 0.015 -} diff --git a/test/connectors/pancakeswap/pancakeswap.routes.test.ts b/test/connectors/pancakeswap/pancakeswap.routes.test.ts deleted file mode 100644 index 497a1e70d2..0000000000 --- a/test/connectors/pancakeswap/pancakeswap.routes.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('Pancakeswap Routes Structure', () => { - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should have router-routes, amm-routes, and clmm-routes folders', () => { - const pancakeswapPath = path.join(__dirname, '../../../src/connectors/pancakeswap'); - const routerRoutesPath = path.join(pancakeswapPath, 'router-routes'); - const ammRoutesPath = path.join(pancakeswapPath, 'amm-routes'); - const clmmRoutesPath = path.join(pancakeswapPath, 'clmm-routes'); - const oldRoutesPath = path.join(pancakeswapPath, 'routes'); - - expect(fs.existsSync(routerRoutesPath)).toBe(true); - expect(fs.existsSync(ammRoutesPath)).toBe(true); - expect(fs.existsSync(clmmRoutesPath)).toBe(true); - expect(fs.existsSync(oldRoutesPath)).toBe(false); - }); - - it('should have correct files in router-routes folder', () => { - const routerRoutesPath = path.join(__dirname, '../../../src/connectors/pancakeswap/router-routes'); - const files = fs.readdirSync(routerRoutesPath); - - expect(files).toContain('executeSwap.ts'); - expect(files).toContain('quoteSwap.ts'); - expect(files).toContain('executeQuote.ts'); - }); - }); - - describe('Route Registration', () => { - it('should register all Pancakeswap route types', async () => { - // Verify routes are registered by checking they return non-404 responses - // (they may return 400 for missing params, but 404 means route doesn't exist) - - // Check router route - const routerResponse = await fastify.inject({ - method: 'GET', - url: '/connectors/pancakeswap/router/quote-swap', - }); - expect(routerResponse.statusCode).not.toBe(404); - - // Check AMM route - const ammResponse = await fastify.inject({ - method: 'GET', - url: '/connectors/pancakeswap/amm/pool-info', - }); - expect(ammResponse.statusCode).not.toBe(404); - - // Check CLMM route - const clmmResponse = await fastify.inject({ - method: 'GET', - url: '/connectors/pancakeswap/clmm/pool-info', - }); - expect(clmmResponse.statusCode).not.toBe(404); - }); - }); -}); diff --git a/test/connectors/pancakeswap/router-routes/universal-router-quoteSwap.test.ts b/test/connectors/pancakeswap/router-routes/universal-router-quoteSwap.test.ts index 7d7b55dc95..01ada89b73 100644 --- a/test/connectors/pancakeswap/router-routes/universal-router-quoteSwap.test.ts +++ b/test/connectors/pancakeswap/router-routes/universal-router-quoteSwap.test.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; import { Pancakeswap } from '../../../../src/connectors/pancakeswap/pancakeswap'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); jest.mock('../../../../src/connectors/pancakeswap/pancakeswap'); @@ -23,7 +24,7 @@ jest.mock('../../../../src/connectors/pancakeswap/universal-router', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/pancakeswap/router-routes/quoteSwap'); + const { quoteSwapRoute } = await import('../../../../src/trading/trading-router-routes/quoteSwap'); await server.register(quoteSwapRoute); return server; }; @@ -161,7 +162,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WBNB', quoteToken: 'USDC', @@ -172,12 +174,12 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId', 'test-quote-id'); expect(body).toHaveProperty('tokenIn', mockWBNB.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); - expect(body).toHaveProperty('amountIn', 1); + expect(Number(body.amountIn)).toBe(1); expect(body).toHaveProperty('amountOut'); expect(body.amountOut).toBeGreaterThan(0); expect(body).toHaveProperty('price'); @@ -209,7 +211,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WBNB', quoteToken: 'USDC', @@ -220,58 +223,62 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('tokenIn', mockUSDC.address); expect(body).toHaveProperty('tokenOut', mockWBNB.address); - expect(body).toHaveProperty('amountOut', 1); + expect(Number(body.amountOut)).toBe(1); expect(body).toHaveProperty('amountIn'); expect(body.amountIn).toBeGreaterThan(0); }); - it('should handle V3 protocol', async () => { + it('quotes through the universal router', async () => { const response = await server.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WBNB', quoteToken: 'USDC', amount: '1', side: 'SELL', slippagePct: '1', - protocols: ['v3'], }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - // Protocols aren't returned in the response - they're only used for filtering + // `protocols` used to be sent here and was silently dropped: the unified router + // route declares no such parameter and never read one, so the "filtering" this case + // was written for never happened. The route answers with the path it chose. expect(body).toHaveProperty('routePath'); }); - it('should handle multiple protocols', async () => { + it('quotes the same pair a second time, without a protocol filter', async () => { const response = await server.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WBNB', quoteToken: 'USDC', amount: '1', side: 'SELL', slippagePct: '1', - protocols: ['v2', 'v3'], }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - // Protocols aren't returned in the response - they're only used for filtering + // `protocols` used to be sent here and was silently dropped: the unified router + // route declares no such parameter and never read one, so the "filtering" this case + // was written for never happened. The route answers with the path it chose. expect(body).toHaveProperty('routePath'); }); @@ -289,7 +296,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'pancakeswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'INVALID', quoteToken: 'USDC', @@ -300,7 +308,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(404); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('message'); expect(body.message).toContain('Token not found'); }); diff --git a/test/connectors/pancakeswap/swap.test.js b/test/connectors/pancakeswap/swap.test.js deleted file mode 100644 index 429f68f580..0000000000 --- a/test/connectors/pancakeswap/swap.test.js +++ /dev/null @@ -1,384 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'pancakeswap'; -const CHAIN = 'ethereum'; -const NETWORK = 'base'; // Testing with Base network, but all Ethereum networks are supported -const BASE_TOKEN = 'WBNB'; -const QUOTE_TOKEN = 'USDC'; -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - try { - // First try to find connector-specific mock - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (error) { - // If not found, use generic mock template - const templatePath = path.join(__dirname, '..', '..', 'templates', 'mock-examples', `connector-${filename}.json`); - return JSON.parse(fs.readFileSync(templatePath, 'utf8')); - } -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' - ); -} - -// Tests -describe('Pancakeswap V3 Swap Router Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Create a mock response based on generic template or existing mock - let mockResponse; - try { - mockResponse = loadMockResponse('quote-swap'); - } catch (error) { - // Create minimal mock if not found - mockResponse = { - estimatedAmountIn: 1.0, - estimatedAmountOut: 1800.0, - minAmountOut: 1782.0, - maxAmountIn: 1.0, - price: 1800.0, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - }; - } - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values for a SELL - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Create a mock response based on generic template or existing mock - let mockBuyResponse; - try { - const mockSellResponse = loadMockResponse('quote-swap'); - mockBuyResponse = { - ...mockSellResponse, - // Flip the values for BUY direction - estimatedAmountIn: mockSellResponse.estimatedAmountOut, // Quote amount needed - estimatedAmountOut: 1.0, // Base amount to receive - minAmountOut: 1.0, - maxAmountIn: mockSellResponse.estimatedAmountOut * 1.01, // Add 1% slippage - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.estimatedAmountOut, // Negative for BUY - // For BUY: price = quote needed / base received - price: mockSellResponse.estimatedAmountOut / 1.0, - }; - } catch (error) { - // Create minimal mock if not found - mockBuyResponse = { - estimatedAmountIn: 1800.0, - estimatedAmountOut: 1.0, - minAmountOut: 1.0, - maxAmountIn: 1818.0, - price: 1800.0, // For BUY: price = quote needed / base received = 1800.0 / 1.0 - baseTokenBalanceChange: 1.0, - quoteTokenBalanceChange: -1800.0, - gasPrice: 5.0, - gasLimit: 300000, - gasCost: 0.0015, - }; - } - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values for a BUY - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - - test('handles different networks correctly', async () => { - const networks = ['mainnet', 'arbitrum', 'optimism', 'base', 'polygon']; - - for (const network of networks) { - const mockResponse = { - estimatedAmountIn: 1.0, - estimatedAmountOut: 1800.0, - minAmountOut: 1782.0, - maxAmountIn: 1.0, - price: 1800.0, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - gasPrice: 5.0, - gasLimit: 300000, - gasCost: 0.0015, - }; - - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, { - params: { - network, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - } - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution with Pancakeswap V3 Swap Router', async () => { - // Create a quote-swap response to use as input for execute-swap - let quoteResponse; - try { - quoteResponse = loadMockResponse('quote-swap'); - } catch (error) { - // Create minimal mock if not found - quoteResponse = { - estimatedAmountIn: 1.0, - estimatedAmountOut: 1800.0, - minAmountOut: 1782.0, - maxAmountIn: 1.0, - price: 1800.0, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - }; - } - - // Mock a successful execution response - const executeResponse = { - signature: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - amountIn: quoteResponse.estimatedAmountIn, - amountOut: quoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: quoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: quoteResponse.quoteTokenBalanceChange, - }; - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(quoteResponse.estimatedAmountIn); - expect(response.data.amountOut).toBe(quoteResponse.estimatedAmountOut); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, - expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }), - ); - }); - - test('executes BUY swap successfully', async () => { - // Create a BUY quote response - const buyQuoteResponse = { - estimatedAmountIn: 2500.0, // USDC needed - estimatedAmountOut: 1.0, // WBNB to receive - minAmountOut: 1.0, - maxAmountIn: 2525.0, // with slippage - price: 2500.0, - baseTokenBalanceChange: 1.0, - quoteTokenBalanceChange: -2500.0, - }; - - // Mock a successful BUY execution response - const executeBuyResponse = { - signature: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - amountIn: buyQuoteResponse.estimatedAmountIn, - amountOut: buyQuoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: buyQuoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: buyQuoteResponse.quoteTokenBalanceChange, - }; - - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeBuyResponse, - }); - - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, // Want to buy 1 WBNB - walletAddress: TEST_WALLET, - }); - - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(2500.0); // USDC spent - expect(response.data.amountOut).toBe(1.0); // WBNB received - expect(response.data.baseTokenBalanceChange).toBe(1.0); // +1 WBNB - expect(response.data.quoteTokenBalanceChange).toBe(-2500.0); // -2500 USDC - }); - - test('validates slippage parameters', async () => { - const executeResponse = { - signature: '0x123...', - amountIn: 1.0, - amountOut: 1790.0, - fee: 0.003, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1790.0, - }; - - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - slippagePct: 1.0, // 1% slippage - }); - - expect(response.status).toBe(200); - // With 1% slippage, the output should be at least 99% of expected - expect(response.data.amountOut).toBeGreaterThanOrEqual(1782.0); - }); - - test('handles multiple networks for execution', async () => { - const networks = ['mainnet', 'arbitrum', 'optimism', 'base']; - - for (const network of networks) { - const executeResponse = { - signature: `0x${network}1234567890abcdef`, - amountIn: 1.0, - amountOut: 1800.0, - fee: 0.003, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - }; - - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - expect(response.status).toBe(200); - expect(response.data.signature).toContain(network); - } - }); - }); -}); diff --git a/test/connectors/price-impact-units.test.ts b/test/connectors/price-impact-units.test.ts new file mode 100644 index 0000000000..2fdd45bd2f --- /dev/null +++ b/test/connectors/price-impact-units.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import path from 'path'; + +import { priceImpactPercentFromFraction } from '../../src/connectors/router-utils'; + +// `QuoteSwapResponse.priceImpactPct` is documented as "Estimated price impact percentage +// (0-100)". Jupiter's field of the same name is a decimal fraction, and it was passed +// through unconverted — 100x low, in the direction that makes a bad trade look harmless. +// Any guard of the form `if (priceImpactPct > 5) reject` could never fire. + +describe('priceImpactPercentFromFraction', () => { + it('reads a fraction as the percentage the schema documents', () => { + // Measured on SOL-USDC: a 20,000 SOL sell reported 0.001260 against a true impact of + // 0.134% computed from the quoted prices — agreement to within the fee. + expect(priceImpactPercentFromFraction('0.001260')).toBeCloseTo(0.126, 6); + expect(priceImpactPercentFromFraction(0.0126)).toBeCloseTo(1.26, 6); + }); + + it('carries a sentinel through as the total it claims to be', () => { + // Jupiter returns 1.0 on thin pools where it cannot compute an impact. Under the + // documented reading that was a 1% impact; it is 100%, which is at least visible. + expect(priceImpactPercentFromFraction('1.0')).toBe(100); + }); + + it('treats a missing value as zero rather than NaN', () => { + expect(priceImpactPercentFromFraction(undefined)).toBe(0); + expect(priceImpactPercentFromFraction(null)).toBe(0); + expect(priceImpactPercentFromFraction('')).toBe(0); + }); +}); + +// The regression is a passthrough, not a wrong formula: `parseFloat(x.priceImpactPct)` +// straight into the unified response. This catches that shape wherever it reappears, +// including in a connector that does not exist yet. +describe('no connector publishes a router fraction as a percentage', () => { + const connectorsDir = path.join(__dirname, '../../src/connectors'); + + const sourceFiles = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.isFile() && entry.name.endsWith('.ts') ? [full] : []; + }); + + it('converts every fraction it publishes', () => { + const offenders: string[] = []; + for (const file of sourceFiles(connectorsDir)) { + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + // The unified field being assigned a parsed router fraction, with no conversion. + const assignment = line.match(/^\s*priceImpactPct:\s*(.+)$/); + if (!assignment) continue; + const rhs = assignment[1]; + if (/parseFloat\(/.test(rhs) && !/priceImpactPercentFromFraction|\*\s*100/.test(rhs)) { + offenders.push(`${path.relative(connectorsDir, file)}: ${line.trim()}`); + } + } + } + + expect(offenders).toEqual([]); + }); + + it('finds assignments to check, so the check cannot pass vacuously', () => { + const assignments = sourceFiles(connectorsDir).filter((file) => + /priceImpactPct:/.test(fs.readFileSync(file, 'utf8')), + ); + + expect(assignments.length).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/test/connectors/raydium/amm-routes/addLiquidity.test.ts b/test/connectors/raydium/amm-routes/addLiquidity.test.ts index 3445230751..9183986882 100644 --- a/test/connectors/raydium/amm-routes/addLiquidity.test.ts +++ b/test/connectors/raydium/amm-routes/addLiquidity.test.ts @@ -3,6 +3,7 @@ import { VersionedTransaction, MessageV0 } from '@solana/web3.js'; import { Solana } from '../../../../src/chains/solana/solana'; import { Raydium } from '../../../../src/connectors/raydium/raydium'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/raydium/raydium'); @@ -13,6 +14,8 @@ jest.mock('../../../../src/services/config-manager-v2', () => ({ ConfigManagerV2: { getInstance: jest.fn().mockReturnValue({ get: jest.fn().mockReturnValue(1), // Default slippage + // Read at import time by the trading routes to build the chainNetwork enum. + getSupportedChainNetworks: jest.fn().mockReturnValue(['solana-devnet', 'solana-mainnet-beta']), }), }, })); @@ -35,7 +38,7 @@ jest.mock('../../../../src/services/logger', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { addLiquidityRoute } = await import('../../../../src/connectors/raydium/amm-routes/addLiquidity'); + const { addLiquidityRoute } = await import('../../../../src/trading/trading-amm-routes/add'); await server.register(addLiquidityRoute); return server; }; @@ -122,6 +125,7 @@ const buildSolanaMock = (overrides: any = {}) => ({ connection: { getTransaction: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-0.999, -149.85], }), @@ -177,9 +181,10 @@ describe('POST /add-liquidity', () => { const response = await server.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, baseTokenAmount: 1, @@ -192,7 +197,7 @@ describe('POST /add-liquidity', () => { console.error('Response error:', response.body); } expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // Owner is set to the wallet public key (wallet-type-agnostic), then sent via the chokepoint. expect(mockRaydiumInstance.setOwner).toHaveBeenCalled(); @@ -202,8 +207,54 @@ describe('POST /add-liquidity', () => { expect(body).toHaveProperty('signature', 'mock-signature'); expect(body).toHaveProperty('status', 1); expect(body.data).toHaveProperty('fee'); - expect(body.data).toHaveProperty('baseTokenAmountAdded'); - expect(body.data).toHaveProperty('quoteTokenAmountAdded'); + + // The values, not just the keys. A deposit's wallet delta is negative — the mock + // returns the live one, [-0.999, -149.85] — and `…Added` reports how much went in, + // so these are the magnitudes. Asserting only that the keys exist accepted the + // negatives that were reaching the event table. + expect(Number(body.data.baseTokenAmountAdded)).toBeCloseTo(0.999, 9); + expect(body.data.quoteTokenAmountAdded).toBeCloseTo(149.85, 9); + }); + + // Named for the defect: hummingbot-api stores data.baseTokenAmountAdded verbatim, so a + // negative here becomes a negative ADD_LIQUIDITY row, and summing the event table nets + // a round trip on this connector while double-counting it on every other one. + it('reports a deposit as a positive amount whichever way the wallet moved', async () => { + const { quoteLiquidity } = require('../../../../src/connectors/raydium/amm-routes/quoteLiquidity'); + quoteLiquidity.mockResolvedValue({ + baseLimited: true, + baseTokenAmount: 0.01, + quoteTokenAmount: 0.848971, + baseTokenAmountMax: 0.0101, + quoteTokenAmountMax: 0.857, + lpTokenAmount: 1, + }); + + (Solana.getInstance as jest.Mock).mockResolvedValue( + buildSolanaMock({ + // The exact deltas of the live add in GW-17. + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-0.01, -0.848971] }), + }), + ); + (Raydium.getInstance as jest.Mock).mockResolvedValue(buildRaydiumMock()); + + const response = await server.inject({ + method: 'POST', + url: '/add', + body: { + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', + walletAddress: mockWalletAddress, + poolAddress: mockPoolAddress, + baseTokenAmount: 0.01, + quoteTokenAmount: 0.848971, + }, + }); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(Number(body.data.baseTokenAmountAdded)).toBe(0.01); + expect(body.data.quoteTokenAmountAdded).toBe(0.848971); }); it('should handle base-limited liquidity addition', async () => { @@ -227,9 +278,10 @@ describe('POST /add-liquidity', () => { const response = await server.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, baseTokenAmount: 1, @@ -263,9 +315,10 @@ describe('POST /add-liquidity', () => { const response = await server.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, baseTokenAmount: 1, @@ -299,9 +352,10 @@ describe('POST /add-liquidity', () => { const response = await server.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: 'invalid-pool', baseTokenAmount: 1, @@ -340,9 +394,10 @@ describe('POST /add-liquidity', () => { const response = await server.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, baseTokenAmount: 1, @@ -393,9 +448,10 @@ describe('POST /add-liquidity', () => { const response = await server.inject({ method: 'POST', - url: '/add-liquidity', + url: '/add', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, baseTokenAmount: 1, diff --git a/test/connectors/raydium/amm-routes/create-pool.test.ts b/test/connectors/raydium/amm-routes/create-pool.test.ts index 32f9bc5314..042fbe9bee 100644 --- a/test/connectors/raydium/amm-routes/create-pool.test.ts +++ b/test/connectors/raydium/amm-routes/create-pool.test.ts @@ -11,7 +11,7 @@ const mockWallet = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/raydium/amm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-amm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -43,7 +43,8 @@ describe('POST /create-pool (Raydium CPMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'SOL', diff --git a/test/connectors/raydium/amm-routes/quote-swap.test.ts b/test/connectors/raydium/amm-routes/quote-swap.test.ts index f8e5836268..9d1f1c5998 100644 --- a/test/connectors/raydium/amm-routes/quote-swap.test.ts +++ b/test/connectors/raydium/amm-routes/quote-swap.test.ts @@ -3,6 +3,7 @@ import { PublicKey } from '@solana/web3.js'; import { Solana } from '../../../../src/chains/solana/solana'; import { Raydium } from '../../../../src/connectors/raydium/raydium'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/raydium/raydium'); @@ -10,8 +11,8 @@ jest.mock('../../../../src/connectors/raydium/raydium'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/raydium/amm-routes/quoteSwap'); - await server.register(quoteSwapRoute); + const { makeQuoteSwapRoute } = await import('../../../../src/trading/pool-swap-routes'); + await server.register(makeQuoteSwapRoute('amm')); return server; }; @@ -143,7 +144,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', poolAddress: mockPoolAddress, baseToken: 'SOL', quoteToken: 'USDC', @@ -154,9 +156,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut', 14.85); expect(body).toHaveProperty('minAmountOut', 14.7); expect(body).toHaveProperty('price', 148.5); @@ -219,7 +221,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', poolAddress: mockPoolAddress, baseToken: 'SOL', quoteToken: 'USDC', @@ -230,9 +233,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); - expect(body).toHaveProperty('amountIn', 15); + expect(Number(body.amountIn)).toBe(15); expect(body).toHaveProperty('amountOut', 0.1); expect(body).toHaveProperty('maxAmountIn', 15.15); expect(body).toHaveProperty('tokenIn', mockUSDC.address); @@ -263,7 +266,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', poolAddress: 'invalid-pool-address', baseToken: 'SOL', quoteToken: 'USDC', @@ -274,6 +278,6 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(404); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); }); diff --git a/test/connectors/raydium/amm.test.js b/test/connectors/raydium/amm.test.js deleted file mode 100644 index 960981cb33..0000000000 --- a/test/connectors/raydium/amm.test.js +++ /dev/null @@ -1,585 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'raydium'; -const PROTOCOL = 'amm'; -const NETWORK = 'mainnet-beta'; -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const TEST_POOL = '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2'; // SOL-USDC Raydium AMM pool -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate pool info response structure -function validatePoolInfo(response) { - return ( - response && - typeof response.address === 'string' && - typeof response.baseTokenAddress === 'string' && - typeof response.quoteTokenAddress === 'string' && - typeof response.feePct === 'number' && - typeof response.price === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - response.poolType === 'amm' && - response.lpMint && - typeof response.lpMint.address === 'string' && - typeof response.lpMint.decimals === 'number' - ); -} - -// Function to validate swap quote response structure based on GetSwapQuoteResponse schema -function validateSwapQuote(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' && - typeof response.computeUnits === 'number' // Updated to use computeUnits - ); -} - -// Function to validate swap execution response structure -function validateSwapExecution(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && // Added status field - (response.status !== 1 || // If not CONFIRMED - (response.data && // then data is optional - typeof response.data.tokenIn === 'string' && - typeof response.data.tokenOut === 'string' && - typeof response.data.amountIn === 'number' && - typeof response.data.amountOut === 'number' && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenBalanceChange === 'number' && - typeof response.data.quoteTokenBalanceChange === 'number')) - ); -} - -// Function to validate liquidity quote response -function validateLiquidityQuote(response) { - return ( - response && - typeof response.baseLimited === 'boolean' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.baseTokenAmountMax === 'number' && - typeof response.quoteTokenAmountMax === 'number' && - typeof response.computeUnits === 'number' // Added computeUnits - ); -} - -// Function to validate position info response -function validatePositionInfo(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.positionId === 'string' && - typeof response.lpTokenAmount === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.shareOfPool === 'number' - ); -} - -// Tests -describe('Raydium AMM Tests (Solana Mainnet)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Pool Info Endpoint', () => { - test('returns and validates pool info', async () => { - // Load mock response - const mockResponse = loadMockResponse('amm-pool-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePoolInfo(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.address).toBe(TEST_POOL); - expect(response.data.poolType).toBe('amm'); - expect(response.data.feePct).toBe(0.0025); // 0.25% fee - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }), - }), - ); - }); - - test('handles error for non-existent pool', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Pool not found for SOL-UNKNOWN', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: 'UNKNOWN', - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('swap-quote'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Modify the mock response for BUY direction - const mockSellResponse = loadMockResponse('swap-quote'); - const mockBuyResponse = { - ...mockSellResponse, - // Flip the values for BUY direction - estimatedAmountIn: mockSellResponse.estimatedAmountOut, - estimatedAmountOut: mockSellResponse.estimatedAmountIn, - maxAmountIn: mockSellResponse.estimatedAmountOut * 1.01, // with slippage - minAmountOut: mockSellResponse.estimatedAmountIn * 0.99, // with slippage - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.quoteTokenBalanceChange, // Negative for BUY - computeUnits: 200000, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - - test('handles insufficient liquidity error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient liquidity in pool for SOL-USDC', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1000000.0, // Very large amount - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient liquidity'), - }, - }, - }); - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Load mock response - const executeResponse = loadMockResponse('swap-execute'); - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapExecution(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.signature).toBeDefined(); - expect(response.data.signature.length).toBeGreaterThan(80); // Solana signatures are long - expect(response.data.status).toBe(1); // CONFIRMED - expect(response.data.data.fee).toBeGreaterThan(0); - }); - - test('handles transaction simulation error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 500, - data: { - error: 'InternalServerError', - message: 'Transaction simulation failed', - code: 500, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1000000.0, - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 500, - data: { - error: 'InternalServerError', - }, - }, - }); - }); - }); - - describe('Quote Liquidity Endpoint', () => { - test('returns and validates liquidity quote', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - baseLimited: false, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - baseTokenAmountMax: 1.0, - quoteTokenAmountMax: 167.5, - lpTokenAmount: 12.94, - shareOfPool: 0.0001, - computeUnits: 150000, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-liquidity`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateLiquidityQuote(response.data)).toBe(true); - expect(response.data.shareOfPool).toBeGreaterThan(0); - }); - }); - - describe('Position Info Endpoint', () => { - test('returns and validates position info', async () => { - const mockResponse = loadMockResponse('amm-pool-info'); - // Add position-specific fields - const positionResponse = { - poolAddress: TEST_POOL, - positionId: 'raydium-amm-lp-123456', - lpTokenAmount: 100.5, - baseTokenAmount: mockResponse.baseTokenAmount * 0.01, - quoteTokenAmount: mockResponse.quoteTokenAmount * 0.01, - shareOfPool: 0.01, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: positionResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 100.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePositionInfo(response.data)).toBe(true); - expect(response.data.lpTokenAmount).toBe(100.5); - }); - }); - - describe('Add Liquidity Endpoint', () => { - test('returns successful liquidity addition', async () => { - const mockResponse = { - signature: '2ZE6KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpS3Yw', - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - lpTokenAmount: 12.94, - poolAddress: TEST_POOL, - fee: 0.005, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.lpTokenAmount).toBeGreaterThan(0); - expect(response.data.baseTokenAmount).toBe(1.0); - expect(response.data.quoteTokenAmount).toBe(167.5); - }); - - test('handles insufficient balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient balance for SOL', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 10000.0, // Large amount - quoteTokenAmount: 1675000.0, - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient balance'), - }, - }, - }); - }); - }); - - describe('Remove Liquidity Endpoint', () => { - test('returns successful liquidity removal', async () => { - const mockResponse = { - signature: '3aF7KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpT4Zx', - baseTokenAmount: 0.95, - quoteTokenAmount: 159.125, - lpTokenAmount: 12.94, - poolAddress: TEST_POOL, - fee: 0.005, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 12.94, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - expect(response.data.lpTokenAmount).toBe(12.94); - }); - - test('handles insufficient LP token balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient LP token balance', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 10000.0, // Large amount - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient LP token balance'), - }, - }, - }); - }); - }); -}); diff --git a/test/connectors/raydium/clmm-routes/addLiquidity.test.ts b/test/connectors/raydium/clmm-routes/addLiquidity.test.ts new file mode 100644 index 0000000000..92f42a2ce1 --- /dev/null +++ b/test/connectors/raydium/clmm-routes/addLiquidity.test.ts @@ -0,0 +1,95 @@ +/** + * Adding to an existing Raydium CLMM position reports how much went in. + * + * This site passed the raw wallet balance change through, so a deposit — which moves + * tokens out — was reported as a negative `…Added`. hummingbot-api stores the value + * verbatim, so the negative reached the event table, where summing the rows nets a round + * trip on this connector while double-counting it on every connector that reports + * magnitudes. No route existed to cover this file before. + */ +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/raydium/raydium'); +jest.mock('../../../../src/connectors/raydium/clmm-routes/quotePosition', () => ({ + quotePosition: jest.fn(), +})); +jest.mock('../../../../src/services/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +import { Solana } from '../../../../src/chains/solana/solana'; +import { addLiquidity } from '../../../../src/connectors/raydium/clmm-routes/addLiquidity'; +import { quotePosition } from '../../../../src/connectors/raydium/clmm-routes/quotePosition'; +import { Raydium } from '../../../../src/connectors/raydium/raydium'; + +const SOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const USDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; +const WALLET = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; +const POSITION = 'AVs9TA4nWDzfPJE9gGVNJMVhcQy3V9PGazuz33BfG2RA'; + +// A deposit of 1 SOL and 150 USDC, as the chain reports it: both sides negative. +const buildSolana = (balanceChanges: number[]) => ({ + getToken: jest.fn(async (address: string) => (address === SOL.address ? SOL : USDC)), + estimateGasPrice: jest.fn().mockResolvedValue(2000), + sendAndConfirmTransactionForWallet: jest.fn().mockResolvedValue({ signature: 'sig' }), + getConfirmedTransactionData: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges }), +}); + +const buildRaydium = () => ({ + setOwner: jest.fn().mockResolvedValue(undefined), + getPositionInfo: jest.fn().mockResolvedValue({ poolAddress: 'pool-1', lowerPrice: 100, upperPrice: 200 }), + getClmmPosition: jest.fn().mockResolvedValue({ poolId: { toBase58: () => 'pool-1' } }), + getClmmPoolfromAPI: jest + .fn() + .mockResolvedValue([ + { mintA: { address: SOL.address, decimals: 9 }, mintB: { address: USDC.address, decimals: 6 } }, + {}, + ]), + raydiumSDK: { clmm: { increasePositionFromBase: jest.fn().mockResolvedValue({ transaction: {} }) } }, +}); + +beforeEach(() => { + jest.clearAllMocks(); + (quotePosition as jest.Mock).mockResolvedValue({ + baseLimited: true, + baseTokenAmount: 1, + quoteTokenAmount: 150, + baseTokenAmountMax: 1.01, + quoteTokenAmountMax: 151.5, + }); +}); + +describe('Raydium CLMM addLiquidity', () => { + it('reports the deposit as magnitudes, not the wallet delta', async () => { + // SOL is the base, so the SOL change is read twice — once as the native entry and + // once as the base — which is how the route indexes a SOL-paired pool. + (Solana.getInstance as jest.Mock).mockResolvedValue(buildSolana([-1, -150])); + (Raydium.getInstance as jest.Mock).mockResolvedValue(buildRaydium()); + + const result = await addLiquidity('mainnet-beta', WALLET, POSITION, 1, 150); + + expect(result.status).toBe(1); + expect(result.data.baseTokenAmountAdded).toBe(1); + expect(result.data.quoteTokenAmountAdded).toBe(150); + }); + + // Adding to a position that already exists locks no new rent, so unlike an open there + // is nothing to back out — the whole outflow is liquidity. + it('does not subtract anything from a native-side deposit', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(buildSolana([-0.5, -75])); + (Raydium.getInstance as jest.Mock).mockResolvedValue(buildRaydium()); + + const result = await addLiquidity('mainnet-beta', WALLET, POSITION, 0.5, 75); + + expect(result.data.baseTokenAmountAdded).toBe(0.5); + }); + + it('names the pool the position belongs to', async () => { + (Solana.getInstance as jest.Mock).mockResolvedValue(buildSolana([-1, -150])); + (Raydium.getInstance as jest.Mock).mockResolvedValue(buildRaydium()); + + const result = await addLiquidity('mainnet-beta', WALLET, POSITION, 1, 150); + + expect(result.data.poolAddress).toBe('pool-1'); + }); +}); diff --git a/test/connectors/raydium/clmm-routes/close-rent-accounting.test.ts b/test/connectors/raydium/clmm-routes/close-rent-accounting.test.ts new file mode 100644 index 0000000000..ec5ae6db33 --- /dev/null +++ b/test/connectors/raydium/clmm-routes/close-rent-accounting.test.ts @@ -0,0 +1,127 @@ +import BN from 'bn.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { Raydium } from '../../../../src/connectors/raydium/raydium'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/raydium/raydium'); + +// A 100% removal closes the position account and its NFT account in the same transaction, +// so their rent lands in the same native balance change as the withdrawal. Reporting the +// raw change called that rent liquidity — the defect GW-31 found on Meteora, present here +// too because the arithmetic was the same. + +const WALLET = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; +const POSITION = 'ARwi1S4DaiTG5DX7S4M4ZsrXqpMD1MrTmbu9ue2tpmEq'; +const SOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const USDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; + +const POSITION_RENT = 2_231_280; +const NFT_ACCOUNT_RENT = 2_039_280; +const RENT = POSITION_RENT + NFT_ACCOUNT_RENT; +const SOL_FROM_THE_POOL = 50_000_000; // 0.05 SOL +const USDC_FROM_THE_POOL = 8.5; +const TX_FEE = 10_000; + +const closeTxData = { + meta: { + fee: TX_FEE, + preBalances: [1_000_000_000, POSITION_RENT, NFT_ACCOUNT_RENT], + postBalances: [1_000_000_000 + RENT + SOL_FROM_THE_POOL - TX_FEE, 0, 0], + preTokenBalances: [{ accountIndex: 2, mint: POSITION, uiTokenAmount: { amount: '1' } }], + postTokenBalances: [], + }, +}; + +// Rent + withdrawal in one number, which is what the chain reports and what the route +// has to take apart. +const nativeChange = (RENT + SOL_FROM_THE_POOL) / 1e9; + +const setup = () => { + (Raydium.getInstance as jest.Mock).mockResolvedValue({ + setOwner: jest.fn(), + getClmmPosition: jest.fn().mockResolvedValue({ + poolId: { toBase58: () => 'pool' }, + liquidity: new BN(1000), + }), + getClmmPoolfromAPI: jest + .fn() + .mockResolvedValue([ + { mintA: { address: SOL.address, symbol: 'SOL' }, mintB: { address: USDC.address, symbol: 'USDC' } }, + {}, + ]), + raydiumSDK: { clmm: { decreaseLiquidity: jest.fn().mockResolvedValue({ transaction: {} }) } }, + }); + + const solana = { + estimateGasPrice: jest.fn().mockResolvedValue(0.001), + sendAndConfirmTransactionForWallet: jest.fn().mockResolvedValue({ signature: 'close-sig' }), + getConfirmedTransactionData: jest.fn().mockResolvedValue(closeTxData), + getToken: jest.fn((t: string) => Promise.resolve(t === SOL.address ? SOL : USDC)), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [nativeChange, USDC_FROM_THE_POOL] }), + }; + (Solana.getInstance as jest.Mock).mockResolvedValue(solana); + return solana; +}; + +describe('raydium CLMM removeLiquidity', () => { + beforeEach(() => jest.clearAllMocks()); + + it('reports the withdrawal, not the rent that came back with it', async () => { + setup(); + const { removeLiquidity } = await import('../../../../src/connectors/raydium/clmm-routes/removeLiquidity'); + + const result = await removeLiquidity('mainnet-beta', WALLET, POSITION, 100, true); + + expect(result.data?.baseTokenAmountRemoved).toBeCloseTo(SOL_FROM_THE_POOL / 1e9, 9); + expect(result.data?.baseTokenAmountRemoved).not.toBeCloseTo(nativeChange, 9); + }); + + it('leaves the non-native side alone, which carries no rent', async () => { + setup(); + const { removeLiquidity } = await import('../../../../src/connectors/raydium/clmm-routes/removeLiquidity'); + + const result = await removeLiquidity('mainnet-beta', WALLET, POSITION, 100, true); + + expect(result.data?.quoteTokenAmountRemoved).toBeCloseTo(USDC_FROM_THE_POOL, 9); + }); + + it('changes nothing for a partial removal, which closes no account', async () => { + setup(); + const noAccountsClosed = { + meta: { fee: TX_FEE, preBalances: [1_000_000_000], postBalances: [1_050_000_000], preTokenBalances: [] }, + }; + const solana = await (Solana.getInstance as jest.Mock)('mainnet-beta'); + solana.getConfirmedTransactionData.mockResolvedValue(noAccountsClosed); + solana.extractBalanceChangesAndFee.mockResolvedValue({ balanceChanges: [0.05, USDC_FROM_THE_POOL] }); + + const { removeLiquidity } = await import('../../../../src/connectors/raydium/clmm-routes/removeLiquidity'); + const result = await removeLiquidity('mainnet-beta', WALLET, POSITION, 50); + + expect(result.data?.baseTokenAmountRemoved).toBeCloseTo(0.05, 9); + }); +}); + +describe('raydium CLMM closePosition', () => { + beforeEach(() => jest.clearAllMocks()); + + it('does not book the reclaimed rent as fee income', async () => { + // The fee is derived by difference — the whole balance change less the liquidity the + // removal reported — so the two have to be measured the same way. Now that the + // removal nets the closed accounts out, this one must too; measuring one net and the + // other gross would turn 0.00427056 SOL of rent into fee income on every close. + const solana = setup(); + (solana as any).extractClmmBalanceChanges = jest.fn().mockResolvedValue({ + baseTokenChange: nativeChange, + quoteTokenChange: USDC_FROM_THE_POOL, + rent: RENT / 1e9, + accountSol: RENT / 1e9, + }); + + const { closePosition } = await import('../../../../src/connectors/raydium/clmm-routes/closePosition'); + const result = await closePosition('mainnet-beta', WALLET, POSITION); + + expect(result.data?.baseFeeAmountCollected).toBeCloseTo(0, 9); + expect(result.data?.positionRentRefunded).toBeCloseTo(RENT / 1e9, 9); + }); +}); diff --git a/test/connectors/raydium/clmm-routes/create-pool.test.ts b/test/connectors/raydium/clmm-routes/create-pool.test.ts index d858f84db9..9a51edaebf 100644 --- a/test/connectors/raydium/clmm-routes/create-pool.test.ts +++ b/test/connectors/raydium/clmm-routes/create-pool.test.ts @@ -19,7 +19,7 @@ const mockWallet = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/raydium/clmm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-clmm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -52,7 +52,8 @@ describe('POST /create-pool (Raydium CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'SOL', @@ -94,7 +95,8 @@ describe('POST /create-pool (Raydium CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWallet, baseToken: 'SOL', quoteToken: 'USDC', diff --git a/test/connectors/raydium/clmm-routes/openPosition.test.ts b/test/connectors/raydium/clmm-routes/openPosition.test.ts index 55fd11f5f1..278d9f28c2 100644 --- a/test/connectors/raydium/clmm-routes/openPosition.test.ts +++ b/test/connectors/raydium/clmm-routes/openPosition.test.ts @@ -3,16 +3,22 @@ import { Keypair, VersionedTransaction, MessageV0 } from '@solana/web3.js'; import { Solana } from '../../../../src/chains/solana/solana'; import { Raydium } from '../../../../src/connectors/raydium/raydium'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/raydium/raydium'); jest.mock('../../../../src/chains/solana/solana.utils', () => ({ + // Spread the real module: only the network lookup needs standing in for, and the + // arithmetic beside it (liquidityWithoutRent) is what the amounts below assert. + ...jest.requireActual('../../../../src/chains/solana/solana.utils'), getAvailableSolanaNetworks: jest.fn().mockReturnValue(['mainnet-beta', 'devnet']), })); jest.mock('../../../../src/services/config-manager-v2', () => ({ ConfigManagerV2: { getInstance: jest.fn().mockReturnValue({ get: jest.fn().mockReturnValue(1), // Default slippage + // Read at import time by the trading routes to build the chainNetwork enum. + getSupportedChainNetworks: jest.fn().mockReturnValue(['solana-devnet', 'solana-mainnet-beta']), }), }, })); @@ -48,7 +54,7 @@ jest.mock('@raydium-io/raydium-sdk-v2', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { openPositionRoute } = await import('../../../../src/connectors/raydium/clmm-routes/openPosition'); + const { openPositionRoute } = await import('../../../../src/trading/trading-clmm-routes/open'); await server.register(openPositionRoute); return server; }; @@ -109,13 +115,19 @@ const buildSolanaMock = (overrides: Record = {}) => ({ connection: { getTransaction: jest.fn().mockResolvedValue(mockTxData), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue(mockTxData), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-0.002, -1, -150], }), extractClmmBalanceChanges: jest.fn().mockResolvedValue({ baseTokenChange: -1, quoteTokenChange: -150, - rent: 0.002, + // What the accounts this transaction created cost, and the rent share of it. Read + // from the transaction rather than assumed: opening a CLMM position creates the + // position, its NFT account, the shared protocol position and sometimes a tick + // array, which is why a fixed 0.00204928 was never the right number. + rent: 0.0132, + accountSol: 0.0132, }), getPositionCache: jest.fn().mockReturnValue({ get: jest.fn(), @@ -182,9 +194,10 @@ describe('POST /open-position', () => { const response = await server.inject({ method: 'POST', - url: '/open-position', + url: '/open', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, lowerPrice: 140, @@ -196,7 +209,7 @@ describe('POST /open-position', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // The SDK owner is set to the wallet's public key (wallet-type-agnostic). expect(mockRaydiumInstance.setOwner).toHaveBeenCalledTimes(1); @@ -207,19 +220,23 @@ describe('POST /open-position', () => { mockWalletAddress, mockSigners, ); - expect(mockSolanaInstance.connection.getTransaction).toHaveBeenCalledWith( - 'mock-signature', - expect.objectContaining({ commitment: 'confirmed', maxSupportedTransactionVersion: 0 }), - ); + expect(mockSolanaInstance.getConfirmedTransactionData).toHaveBeenCalledWith('mock-signature'); // Verify the response expect(body).toHaveProperty('signature', 'mock-signature'); expect(body).toHaveProperty('status', 1); expect(body.data).toHaveProperty('positionAddress', mockPositionNftMint); expect(body.data).toHaveProperty('fee'); - expect(body.data).toHaveProperty('positionRent'); - expect(body.data).toHaveProperty('baseTokenAmountAdded'); - expect(body.data).toHaveProperty('quoteTokenAmountAdded'); + expect(Number(body.data.positionRent)).toBe(0.0132); + + // The values, not just the keys. The mocked wallet deltas are -1 SOL and -150 USDC + // with 0.0132 SOL locked across the accounts the open created, and an open reports + // what the position holds: magnitudes, with those lamports — which the chain returns + // on close, so they are locked rather than deposited — backed off the native side + // only. Asserting the keys existed accepted both the negative and the rent counted as + // liquidity. + expect(body.data.baseTokenAmountAdded).toBeCloseTo(0.9868, 9); + expect(body.data.quoteTokenAmountAdded).toBeCloseTo(150, 9); }); it('should set the owner before pool operations', async () => { @@ -260,9 +277,10 @@ describe('POST /open-position', () => { const response = await server.inject({ method: 'POST', - url: '/open-position', + url: '/open', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, lowerPrice: 140, @@ -299,9 +317,10 @@ describe('POST /open-position', () => { const response = await server.inject({ method: 'POST', - url: '/open-position', + url: '/open', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: 'invalid-wallet', poolAddress: mockPoolAddress, lowerPrice: 140, @@ -336,9 +355,10 @@ describe('POST /open-position', () => { const response = await server.inject({ method: 'POST', - url: '/open-position', + url: '/open', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: 'invalid-pool', lowerPrice: 140, @@ -377,9 +397,10 @@ describe('POST /open-position', () => { const response = await server.inject({ method: 'POST', - url: '/open-position', + url: '/open', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, lowerPrice: 160, // Lower price is higher than upper price @@ -423,9 +444,10 @@ describe('POST /open-position', () => { const response = await server.inject({ method: 'POST', - url: '/open-position', + url: '/open', body: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, poolAddress: mockPoolAddress, lowerPrice: 140, diff --git a/test/connectors/raydium/clmm-routes/poolInfo.test.ts b/test/connectors/raydium/clmm-routes/poolInfo.test.ts index 06ccc3cea8..a64754b028 100644 --- a/test/connectors/raydium/clmm-routes/poolInfo.test.ts +++ b/test/connectors/raydium/clmm-routes/poolInfo.test.ts @@ -1,5 +1,6 @@ import { Raydium } from '../../../../src/connectors/raydium/raydium'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/connectors/raydium/raydium'); jest.mock('../../../../src/chains/solana/solana'); @@ -16,8 +17,8 @@ jest.mock('../../../../src/connectors/raydium/raydium.utils', () => { const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { poolInfoRoute } = await import('../../../../src/connectors/raydium/clmm-routes/poolInfo'); - await server.register(poolInfoRoute); + const { poolsRoute } = await import('../../../../src/trading/clmm/pools'); + await server.register(poolsRoute); return server; }; @@ -78,10 +79,10 @@ describe('GET /pool-info (raydium clmm)', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'raydium', poolAddress: mockPoolAddress }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toEqual( expect.objectContaining({ address: mockPoolAddress, @@ -101,7 +102,7 @@ describe('GET /pool-info (raydium clmm)', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'raydium', poolAddress: mockPoolAddress }, }); expect(response.statusCode).toBe(404); }); @@ -120,10 +121,10 @@ describe('GET /pool-info (raydium clmm)', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'raydium', poolAddress: mockPoolAddress }, }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body).bins).toBeUndefined(); + expect(parseWire(response.body).bins).toBeUndefined(); expect(computeRaydiumBinDistribution).not.toHaveBeenCalled(); }); @@ -132,10 +133,10 @@ describe('GET /pool-info (raydium clmm)', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress, binCount: 0 }, + query: { chainNetwork: 'solana-mainnet-beta', connector: 'raydium', poolAddress: mockPoolAddress, binCount: 0 }, }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body).bins).toBeUndefined(); + expect(parseWire(response.body).bins).toBeUndefined(); expect(computeRaydiumBinDistribution).not.toHaveBeenCalled(); }); @@ -145,10 +146,15 @@ describe('GET /pool-info (raydium clmm)', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress, binCount: 11 }, + query: { + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', + poolAddress: mockPoolAddress, + binCount: 11, + }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(Array.isArray(body.bins)).toBe(true); expect(body.bins).toHaveLength(11); expect(body.bins[0]).toEqual( @@ -170,7 +176,12 @@ describe('GET /pool-info (raydium clmm)', () => { const response = await app.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet-beta', poolAddress: mockPoolAddress, binCount: 999 }, + query: { + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', + poolAddress: mockPoolAddress, + binCount: 999, + }, }); expect(response.statusCode).toBe(400); }); diff --git a/test/connectors/raydium/clmm-routes/positionsOwned.test.ts b/test/connectors/raydium/clmm-routes/positionsOwned.test.ts index a3611ea06a..32a086a2b0 100644 --- a/test/connectors/raydium/clmm-routes/positionsOwned.test.ts +++ b/test/connectors/raydium/clmm-routes/positionsOwned.test.ts @@ -10,7 +10,7 @@ jest.mock('../../../../src/connectors/raydium/raydium'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { positionsOwnedRoute } = await import('../../../../src/connectors/raydium/clmm-routes/positionsOwned'); + const { positionsOwnedRoute } = await import('../../../../src/trading/clmm/positions-owned'); await server.register(positionsOwnedRoute); return server; }; @@ -100,7 +100,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, }, }); @@ -136,7 +137,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, }, }); @@ -152,7 +154,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: 'invalid-address', }, }); @@ -160,18 +163,6 @@ describe('GET /positions-owned', () => { expect(response.statusCode).toBe(400); }); - it('should return 400 when walletAddress is missing', async () => { - const response = await app.inject({ - method: 'GET', - url: '/positions-owned', - query: { - network: 'mainnet-beta', - }, - }); - - expect(response.statusCode).toBe(400); - }); - it('should query multiple program IDs', async () => { const mockRaydiumSDKMulti = { clmm: { @@ -197,7 +188,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, }, }); @@ -226,7 +218,8 @@ describe('GET /positions-owned', () => { method: 'GET', url: '/positions-owned', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', walletAddress: mockWalletAddress, }, }); diff --git a/test/connectors/raydium/clmm-routes/quote-swap.test.ts b/test/connectors/raydium/clmm-routes/quote-swap.test.ts index 5e622908a5..f9dae29876 100644 --- a/test/connectors/raydium/clmm-routes/quote-swap.test.ts +++ b/test/connectors/raydium/clmm-routes/quote-swap.test.ts @@ -3,6 +3,7 @@ import { PoolUtils } from '@raydium-io/raydium-sdk-v2'; import { Solana } from '../../../../src/chains/solana/solana'; import { Raydium } from '../../../../src/connectors/raydium/raydium'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/raydium/raydium'); @@ -29,8 +30,8 @@ jest.mock('@raydium-io/raydium-sdk-v2', () => { const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/raydium/clmm-routes/quoteSwap'); - await server.register(quoteSwapRoute); + const { makeQuoteSwapRoute } = await import('../../../../src/trading/pool-swap-routes'); + await server.register(makeQuoteSwapRoute('clmm')); return server; }; @@ -113,7 +114,8 @@ describe('GET /quote-swap (Raydium CLMM)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', poolAddress: mockPoolAddress, baseToken: 'SOL', quoteToken: 'USDC', @@ -124,11 +126,11 @@ describe('GET /quote-swap (Raydium CLMM)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); expect(body).toHaveProperty('tokenIn', mockSOL.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); - expect(body).toHaveProperty('amountIn', 0.2); + expect(Number(body.amountIn)).toBe(0.2); expect(body).toHaveProperty('amountOut', 13); expect(body).toHaveProperty('price', 65); expect(body).toHaveProperty('maxAmountIn', 0.2); @@ -156,7 +158,8 @@ describe('GET /quote-swap (Raydium CLMM)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', poolAddress: mockPoolAddress, baseToken: 'SOL', quoteToken: 'USDC', @@ -167,11 +170,11 @@ describe('GET /quote-swap (Raydium CLMM)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); expect(body).toHaveProperty('tokenIn', mockUSDC.address); expect(body).toHaveProperty('tokenOut', mockSOL.address); - expect(body).toHaveProperty('amountIn', 13); + expect(Number(body.amountIn)).toBe(13); expect(body).toHaveProperty('amountOut', 0.2); expect(body).toHaveProperty('price', 65); // The core regression: maxAmountIn must be GREATER than amountIn (was inverted before). @@ -198,7 +201,8 @@ describe('GET /quote-swap (Raydium CLMM)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'raydium', poolAddress: 'invalid-pool-address', baseToken: 'SOL', quoteToken: 'USDC', @@ -209,6 +213,6 @@ describe('GET /quote-swap (Raydium CLMM)', () => { }); expect(response.statusCode).toBe(404); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); }); diff --git a/test/connectors/raydium/clmm.test.js b/test/connectors/raydium/clmm.test.js deleted file mode 100644 index 0d3eac09ef..0000000000 --- a/test/connectors/raydium/clmm.test.js +++ /dev/null @@ -1,686 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'raydium'; -const PROTOCOL = 'clmm'; -const NETWORK = 'mainnet-beta'; -const BASE_TOKEN = 'SOL'; -const QUOTE_TOKEN = 'USDC'; -const TEST_POOL = '3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv'; // SOL-USDC Raydium CLMM pool -const TEST_WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; -const TEST_POSITION_ID = '123456789'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate pool info response structure -function validatePoolInfo(response) { - return ( - response && - typeof response.address === 'string' && - typeof response.baseTokenAddress === 'string' && - typeof response.quoteTokenAddress === 'string' && - typeof response.feePct === 'number' && - typeof response.price === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.currentTick === 'number' && - typeof response.liquidity === 'string' - ); -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' && - typeof response.computeUnits === 'number' // Updated to use computeUnits - ); -} - -// Function to validate position info response structure -function validatePositionInfo(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.positionId === 'string' && - typeof response.lowerTick === 'number' && - typeof response.upperTick === 'number' && - typeof response.liquidity === 'string' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.unclaimedFeeBaseAmount === 'number' && - typeof response.unclaimedFeeQuoteAmount === 'number' - ); -} - -// Function to validate quote position response -function validateQuotePosition(response) { - return ( - response && - typeof response.baseLimited === 'boolean' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.baseTokenAmountMax === 'number' && - typeof response.quoteTokenAmountMax === 'number' && - response.liquidity !== undefined && // Can be string or object - typeof response.computeUnits === 'number' // Added computeUnits - ); -} - -// Function to validate open position response -function validateOpenPosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.positionAddress === 'string' && - typeof response.data.positionRent === 'number' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate add liquidity response -function validateAddLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate remove liquidity response -function validateRemoveLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number')) - ); -} - -// Function to validate close position response -function validateClosePosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.positionRentRefunded === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number' && - typeof response.data.baseFeeAmountCollected === 'number' && - typeof response.data.quoteFeeAmountCollected === 'number')) - ); -} - -// Tests -describe('Raydium CLMM Tests (Solana Mainnet)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Pool Info Endpoint', () => { - test('returns and validates pool info', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-pool-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePoolInfo(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.address).toBe(TEST_POOL); - expect(response.data.feePct).toBe(0.04); // Typical Raydium CLMM fee - expect(response.data.currentTick).toBeDefined(); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }), - }), - ); - }); - - test('handles error for non-existent pool', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Pool not found for SOL-UNKNOWN', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: 'UNKNOWN', - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-swap-quote'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - }); - - test('returns and validates swap quote for BUY', async () => { - // Modify the mock response for BUY direction - const mockSellResponse = loadMockResponse('clmm-swap-quote'); - const mockBuyResponse = { - ...mockSellResponse, - estimatedAmountIn: mockSellResponse.estimatedAmountOut, - estimatedAmountOut: mockSellResponse.estimatedAmountIn, - maxAmountIn: mockSellResponse.estimatedAmountOut * 1.01, - minAmountOut: mockSellResponse.estimatedAmountIn * 0.99, - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.quoteTokenBalanceChange, // Negative for BUY - computeUnits: 300000, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Load mock response - const executeResponse = loadMockResponse('swap-execute'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.signature.length).toBeGreaterThan(80); // Solana signatures are long - }); - }); - - describe('Position Info Endpoint', () => { - test('returns and validates position info', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-position-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: TEST_POSITION_ID, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePositionInfo(response.data)).toBe(true); - expect(response.data.positionId).toBe(TEST_POSITION_ID); - expect(response.data.liquidity).toBeDefined(); - }); - - test('handles position not found error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Position not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: 'invalid-position', - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Positions Owned Endpoint', () => { - test('returns list of owned positions', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-positions-owned'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/positions-owned`, { - params: { - network: NETWORK, - walletAddress: TEST_WALLET, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(Array.isArray(response.data)).toBe(true); - expect(response.data.length).toBeGreaterThan(0); - - // Validate first position - const firstPosition = response.data[0]; - expect(validatePositionInfo(firstPosition)).toBe(true); - }); - }); - - describe('Quote Position Endpoint', () => { - test('returns and validates quote for new position', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseLimited: false, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - baseTokenAmountMax: 1.0, - quoteTokenAmountMax: 167.5, - liquidity: '1294000000', - shareOfPool: 0.0001, - computeUnits: 150000, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateQuotePosition(response.data)).toBe(true); - expect(response.data.liquidity).toBeDefined(); - expect(response.data.shareOfPool).toBeGreaterThan(0); - }); - - test('handles invalid tick range error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Invalid tick range', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: 100, - upperTick: 50, // Invalid: upper < lower - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Invalid tick range'), - }, - }, - }); - }); - }); - - describe('Open Position Endpoint', () => { - test('returns successful position opening', async () => { - const mockResponse = loadMockResponse('clmm-add-liquidity'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBeDefined(); - expect(response.data.liquidity).toBeDefined(); - }); - - test('handles insufficient balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient balance for SOL', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 10000.0, // Large amount - quoteTokenAmount: 1675000.0, - walletAddress: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient balance'), - }, - }, - }); - }); - }); - - describe('Add Liquidity Endpoint', () => { - test('returns successful liquidity addition to existing position', async () => { - const mockResponse = loadMockResponse('clmm-add-liquidity'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - baseTokenAmount: 0.5, - quoteTokenAmount: 83.75, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe(TEST_POSITION_ID); - expect(response.data.liquidity).toBeDefined(); - }); - }); - - describe('Remove Liquidity Endpoint', () => { - test('returns successful liquidity removal', async () => { - const mockResponse = { - signature: '4bF7KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpU5Ay', - positionId: TEST_POSITION_ID, - baseTokenAmount: 0.95, - quoteTokenAmount: 159.125, - liquidityRemoved: '647000000', - fee: 0.005, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - liquidity: '647000000', - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - expect(response.data.liquidityRemoved).toBe('647000000'); - }); - }); - - describe('Close Position Endpoint', () => { - test('returns successful position closure', async () => { - const mockResponse = { - signature: '5cG8KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpV6Bz', - positionId: TEST_POSITION_ID, - baseTokenAmount: 1.0, - quoteTokenAmount: 167.5, - feeBaseAmount: 0.01, - feeQuoteAmount: 1.675, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/close-position`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe(TEST_POSITION_ID); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - }); - }); - - describe('Collect Fees Endpoint', () => { - test('returns successful fee collection', async () => { - // Load mock response - const mockResponse = loadMockResponse('clmm-collect-fees'); - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/collect-fees`, { - network: NETWORK, - positionId: TEST_POSITION_ID, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.feeBaseAmount).toBeGreaterThanOrEqual(0); - expect(response.data.feeQuoteAmount).toBeGreaterThanOrEqual(0); - }); - }); -}); diff --git a/test/connectors/raydium/mocks/amm-pool-info-invalid.json b/test/connectors/raydium/mocks/amm-pool-info-invalid.json deleted file mode 100644 index 6cb0d06ba4..0000000000 --- a/test/connectors/raydium/mocks/amm-pool-info-invalid.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "InternalServerError", - "message": "Failed to fetch pool info" -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/amm-pool-info.json b/test/connectors/raydium/mocks/amm-pool-info.json deleted file mode 100644 index 327ecd156e..0000000000 --- a/test/connectors/raydium/mocks/amm-pool-info.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.0025, - "price": 163.5123715722968, - "baseTokenAmount": 40078.234338467, - "quoteTokenAmount": 6553287.145113, - "lpMint": { - "address": "8HoQnePLqPj4M7PUDzfw8e3Ymdwgc7NLGnaTUapubyvu", - "decimals": 9 - }, - "poolType": "amm" -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/amm-position-info.json b/test/connectors/raydium/mocks/amm-position-info.json deleted file mode 100644 index 393be88ddd..0000000000 --- a/test/connectors/raydium/mocks/amm-position-info.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "poolAddress": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "walletAddress": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "lpTokenAmount": 0, - "baseTokenAmount": 0, - "quoteTokenAmount": 0, - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "price": 163.44313726479947 -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/amm-quote-liquidity-imbalanced.json b/test/connectors/raydium/mocks/amm-quote-liquidity-imbalanced.json deleted file mode 100644 index eaee4fe1b6..0000000000 --- a/test/connectors/raydium/mocks/amm-quote-liquidity-imbalanced.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "statusCode": 400, - "error": "Validation Error", - "message": "querystring must have required property 'baseTokenAmount'", - "validation": [ - { - "instancePath": "", - "schemaPath": "#/required", - "keyword": "required", - "params": { - "missingProperty": "baseTokenAmount" - }, - "message": "must have required property 'baseTokenAmount'" - } - ] -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/amm-quote-liquidity.json b/test/connectors/raydium/mocks/amm-quote-liquidity.json deleted file mode 100644 index 8a675b5de2..0000000000 --- a/test/connectors/raydium/mocks/amm-quote-liquidity.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "lpTokensEstimated": 0.001, - "baseAmountEstimated": 0.01, - "quoteAmountEstimated": 1.63, - "shareOfPool": 0.00001 -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/amm-quote-swap-invalid-token.json b/test/connectors/raydium/mocks/amm-quote-swap-invalid-token.json deleted file mode 100644 index a8dc0a08f6..0000000000 --- a/test/connectors/raydium/mocks/amm-quote-swap-invalid-token.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "InternalServerError", - "message": "Internal server error" -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/amm-quote-swap-sell.json b/test/connectors/raydium/mocks/amm-quote-swap-sell.json deleted file mode 100644 index 48fb2d60e4..0000000000 --- a/test/connectors/raydium/mocks/amm-quote-swap-sell.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "estimatedAmountIn": 0.01, - "estimatedAmountOut": 1.631035, - "minAmountOut": 1.614725, - "maxAmountIn": 0.01, - "baseTokenBalanceChange": -0.01, - "quoteTokenBalanceChange": 1.631035, - "price": 163.1035, - "computeUnits": 200000 -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/amm-remove-liquidity.json b/test/connectors/raydium/mocks/amm-remove-liquidity.json deleted file mode 100644 index fe76534136..0000000000 --- a/test/connectors/raydium/mocks/amm-remove-liquidity.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "signature": "2Ek4Xo5oGhQNUoVa9P7uXBjXzHcZQAhQqJrMcF8VKZRhTQkxvhLVVNjKfQQVBSvvzEKkUxTGSxgXvZeKuLKdWnqP", - "status": 1, - "data": { - "lpTokensRemoved": 0.001, - "baseTokenReceived": 0.01, - "quoteTokenReceived": 1.52, - "fee": 0.000005 - } -} diff --git a/test/connectors/raydium/mocks/balance.json b/test/connectors/raydium/mocks/balance.json deleted file mode 100644 index c92760b58e..0000000000 --- a/test/connectors/raydium/mocks/balance.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "validResponse": { - "balances": { - "SOL": 2.5, - "USDC": 1000.0, - "USDT": 500.0 - } - }, - "invalidResponse": { - "balances": { - "SOL": "2.5", - "USDC": 1000.0, - "USDT": 500.0 - } - } - }, - { - "validResponse": { - "balances": { - "SOL": 2.5, - "USDC": 1000.0, - "USDT": 500.0 - } - }, - "invalidResponse": { - "balances": [ - { "symbol": "SOL", "amount": 2.5 }, - { "symbol": "USDC", "amount": 1000.0 }, - { "symbol": "USDT", "amount": 500.0 } - ] - } - } -] \ No newline at end of file diff --git a/test/connectors/raydium/mocks/clmm-add-liquidity.json b/test/connectors/raydium/mocks/clmm-add-liquidity.json deleted file mode 100644 index a5157e7efc..0000000000 --- a/test/connectors/raydium/mocks/clmm-add-liquidity.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "signature": "3ZE6KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpT3Zx", - "positionId": "123456789", - "poolAddress": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "liquidity": "1294000000", - "baseTokenAmount": 1.0, - "quoteTokenAmount": 167.5, - "fee": 0.005 -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/clmm-collect-fees.json b/test/connectors/raydium/mocks/clmm-collect-fees.json deleted file mode 100644 index ce7a97745a..0000000000 --- a/test/connectors/raydium/mocks/clmm-collect-fees.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "signature": "6dG9KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpW7Dc", - "positionId": "123456789", - "feeBaseAmount": 0.001, - "feeQuoteAmount": 0.1675 -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/clmm-pool-info.json b/test/connectors/raydium/mocks/clmm-pool-info.json deleted file mode 100644 index 68373272d9..0000000000 --- a/test/connectors/raydium/mocks/clmm-pool-info.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "address": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.04, - "price": 167.5, - "baseTokenAmount": 12500.0, - "quoteTokenAmount": 2093750.0, - "currentTick": 23100, - "liquidity": "5000000000000000000" -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/clmm-position-info.json b/test/connectors/raydium/mocks/clmm-position-info.json deleted file mode 100644 index 0cddbf1e77..0000000000 --- a/test/connectors/raydium/mocks/clmm-position-info.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "positionId": "123456789", - "lowerTick": -887272, - "upperTick": 887272, - "liquidity": "1000000000000000000", - "baseTokenAmount": 1.5, - "quoteTokenAmount": 251.25, - "unclaimedFeeBaseAmount": 0.001, - "unclaimedFeeQuoteAmount": 0.1675 -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/clmm-positions-owned.json b/test/connectors/raydium/mocks/clmm-positions-owned.json deleted file mode 100644 index 2abdee250d..0000000000 --- a/test/connectors/raydium/mocks/clmm-positions-owned.json +++ /dev/null @@ -1,24 +0,0 @@ -[ - { - "poolAddress": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "positionId": "123456789", - "lowerTick": -887272, - "upperTick": 887272, - "liquidity": "1000000000000000000", - "baseTokenAmount": 1.5, - "quoteTokenAmount": 251.25, - "unclaimedFeeBaseAmount": 0.001, - "unclaimedFeeQuoteAmount": 0.1675 - }, - { - "poolAddress": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "positionId": "987654321", - "lowerTick": -443636, - "upperTick": 443636, - "liquidity": "500000000000000000", - "baseTokenAmount": 0.75, - "quoteTokenAmount": 125.625, - "unclaimedFeeBaseAmount": 0.0005, - "unclaimedFeeQuoteAmount": 0.08375 - } -] \ No newline at end of file diff --git a/test/connectors/raydium/mocks/clmm-quote-swap-sell.json b/test/connectors/raydium/mocks/clmm-quote-swap-sell.json deleted file mode 100644 index e8266a31c3..0000000000 --- a/test/connectors/raydium/mocks/clmm-quote-swap-sell.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "61R1ndXxvsWXXkWSyNkCxnzwd3zUNB8Q2ibmkiLPC8ht", - "estimatedAmountIn": 10, - "estimatedAmountOut": 21.379539, - "minAmountOut": 21.379539, - "maxAmountIn": 10, - "baseTokenBalanceChange": -10, - "quoteTokenBalanceChange": 21.379539, - "price": 2.1379539, - "computeUnits": 600000 -} diff --git a/test/connectors/raydium/mocks/clmm-swap-quote.json b/test/connectors/raydium/mocks/clmm-swap-quote.json deleted file mode 100644 index 408ed396f7..0000000000 --- a/test/connectors/raydium/mocks/clmm-swap-quote.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 167.5, - "minAmountOut": 166.1625, - "maxAmountIn": 1.01, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 167.5, - "price": 167.5, - "computeUnits": 300000 -} \ No newline at end of file diff --git a/test/connectors/raydium/mocks/swap-execute.json b/test/connectors/raydium/mocks/swap-execute.json deleted file mode 100644 index 0a1e404f08..0000000000 --- a/test/connectors/raydium/mocks/swap-execute.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "signature": "2ZE6KhhZTPixeNQVxjDv2LcX7VTxQN9vwMv8Z89FwqYKKQRmqPQCuwyWQMjGwUJKdRrPoKNL7Rn6fHZFvVbpS3Yw", - "status": 1, - "data": { - "tokenIn": "So11111111111111111111111111111111111111112", - "tokenOut": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "amountIn": 1.0, - "amountOut": 167.5, - "fee": 0.0025, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 167.5 - } -} diff --git a/test/connectors/raydium/mocks/swap-quote.json b/test/connectors/raydium/mocks/swap-quote.json deleted file mode 100644 index 9cd0acdc58..0000000000 --- a/test/connectors/raydium/mocks/swap-quote.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 167.5, - "minAmountOut": 166.1625, - "maxAmountIn": 1.01, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 167.5, - "price": 167.5, - "computeUnits": 300000 -} \ No newline at end of file diff --git a/test/connectors/raydium/raydium.routes.test.ts b/test/connectors/raydium/raydium.routes.test.ts deleted file mode 100644 index d9d29a8a6f..0000000000 --- a/test/connectors/raydium/raydium.routes.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('Raydium Routes Structure', () => { - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should only have amm-routes and clmm-routes folders', () => { - const raydiumPath = path.join(__dirname, '../../../src/connectors/raydium'); - const ammRoutesPath = path.join(raydiumPath, 'amm-routes'); - const clmmRoutesPath = path.join(raydiumPath, 'clmm-routes'); - const swapRoutesPath = path.join(raydiumPath, 'swap-routes'); - const routesPath = path.join(raydiumPath, 'routes'); - - expect(fs.existsSync(ammRoutesPath)).toBe(true); - expect(fs.existsSync(clmmRoutesPath)).toBe(true); - expect(fs.existsSync(swapRoutesPath)).toBe(false); - expect(fs.existsSync(routesPath)).toBe(false); - }); - - it('should have swap endpoints within AMM routes', () => { - const ammRoutesPath = path.join(__dirname, '../../../src/connectors/raydium/amm-routes'); - const files = fs.readdirSync(ammRoutesPath); - - expect(files).toContain('executeSwap.ts'); - expect(files).toContain('quoteSwap.ts'); - }); - - it('should have swap endpoints within CLMM routes', () => { - const clmmRoutesPath = path.join(__dirname, '../../../src/connectors/raydium/clmm-routes'); - const files = fs.readdirSync(clmmRoutesPath); - - expect(files).toContain('executeSwap.ts'); - expect(files).toContain('quoteSwap.ts'); - }); - }); - - describe('Route Registration', () => { - it('should register Raydium AMM and CLMM routes', async () => { - const routes = fastify.printRoutes(); - - // Check that Raydium AMM routes are registered - expect(routes).toContain('raydium/'); - expect(routes).toContain('amm/'); - - // Check that Raydium CLMM routes are registered - expect(routes).toContain('clmm/'); - - // Check that swap routes are NOT directly under /swap - expect(routes).not.toContain('raydium/swap/'); - }); - }); -}); diff --git a/test/connectors/raydium/raydium.test.ts b/test/connectors/raydium/raydium.test.ts index d553224ad0..d2828c4127 100644 --- a/test/connectors/raydium/raydium.test.ts +++ b/test/connectors/raydium/raydium.test.ts @@ -375,7 +375,8 @@ describe('Raydium', () => { address: mockPoolAddress, baseTokenAddress: 'base-token', quoteTokenAddress: 'quote-token', - feePct: 0.0025, + // 25/10000 is the fraction; feePct is a percent, so 0.25% (GW-2). + feePct: 0.25, price: 100, baseTokenAmount: 1, quoteTokenAmount: 100, @@ -417,7 +418,8 @@ describe('Raydium', () => { address: mockPoolAddress, baseTokenAddress: 'base-token', quoteTokenAddress: 'quote-token', - feePct: 30, + // CPMM tradeFeeRate is in millionths, so the mock's 30 is 0.003% (GW-2). + feePct: 0.003, price: 200, baseTokenAmount: 2, quoteTokenAmount: 400, diff --git a/test/connectors/router-attempted-route.test.ts b/test/connectors/router-attempted-route.test.ts new file mode 100644 index 0000000000..419c94f7a6 --- /dev/null +++ b/test/connectors/router-attempted-route.test.ts @@ -0,0 +1,32 @@ +import { attemptedRoute } from '../../src/connectors/router-utils'; + +// A no-route error is the one message a caller may act on automatically — it reads as +// "this token is untradable", and the routers' own comments record callers blacklisting +// good pools over a mislabelled one. Every router built this message from the SELL shape +// and reused it for BUY, so a BUY that failed named the opposite direction and the +// opposite mode: a route nobody had tried. + +describe('attemptedRoute', () => { + it('describes a SELL as ExactIn, base to quote', () => { + expect(attemptedRoute('SELL', 'DOGE-1', 'SOL')).toBe('DOGE-1 -> SOL (ExactIn)'); + }); + + it('describes a BUY as ExactOut, quote to base', () => { + // The live case: a BUY of DOGE-1 with approximation declined was reported as + // "No route found for DOGE-1 -> SOL (ExactIn)" — both halves wrong, and wrong in + // the direction that condemns a token which routes ExactIn perfectly well. + expect(attemptedRoute('BUY', 'DOGE-1', 'SOL')).toBe('SOL -> DOGE-1 (ExactOut)'); + }); + + it('takes an explicit mode for a router whose executable mode differs from the side', () => { + // OKX names its own mode; the direction still follows the side. + expect(attemptedRoute('BUY', 'DOGE-1', 'SOL', 'exactOut')).toBe('SOL -> DOGE-1 (exactOut)'); + expect(attemptedRoute('BUY', 'DOGE-1', 'SOL', 'ExactOut, ExactIn fallback failed')).toBe( + 'SOL -> DOGE-1 (ExactOut, ExactIn fallback failed)', + ); + }); + + it('never reports the two sides the same way', () => { + expect(attemptedRoute('BUY', 'A', 'B')).not.toBe(attemptedRoute('SELL', 'A', 'B')); + }); +}); diff --git a/test/connectors/titan/router-routes/executeQuote.test.ts b/test/connectors/titan/router-routes/executeQuote.test.ts index e66955d2eb..8577384857 100644 --- a/test/connectors/titan/router-routes/executeQuote.test.ts +++ b/test/connectors/titan/router-routes/executeQuote.test.ts @@ -12,7 +12,7 @@ jest.mock('../../../../src/connectors/titan/titan.utils', () => ({ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { executeQuoteRoute } = await import('../../../../src/connectors/titan/router-routes/executeQuote'); + const { executeQuoteRoute } = await import('../../../../src/trading/trading-router-routes/executeQuote'); await server.register(executeQuoteRoute); return server; }; @@ -69,6 +69,7 @@ describe('POST /execute-quote (titan)', () => { const mockSolanaInstance = { sendAndConfirmTransactionForWallet, connection: mockConnection, + getConfirmedTransactionData: jest.fn(async () => ({ meta: {} })), handleConfirmation: jest.fn(async () => confirmedResult), }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); @@ -79,7 +80,12 @@ describe('POST /execute-quote (titan)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: WALLET, network: 'mainnet-beta', quoteId: 'titan-quote-1' }, + body: { + walletAddress: WALLET, + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', + quoteId: 'titan-quote-1', + }, }); expect(response.statusCode).toBe(200); @@ -100,7 +106,12 @@ describe('POST /execute-quote (titan)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: OTHER_WALLET, network: 'mainnet-beta', quoteId: 'titan-quote-2' }, + body: { + walletAddress: OTHER_WALLET, + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', + quoteId: 'titan-quote-2', + }, }); expect(response.statusCode).toBe(400); @@ -113,7 +124,12 @@ describe('POST /execute-quote (titan)', () => { const response = await server.inject({ method: 'POST', url: '/execute-quote', - body: { walletAddress: WALLET, network: 'mainnet-beta', quoteId: 'missing-quote' }, + body: { + walletAddress: WALLET, + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', + quoteId: 'missing-quote', + }, }); expect(response.statusCode).toBe(400); diff --git a/test/connectors/titan/router-routes/quoteSwap.test.ts b/test/connectors/titan/router-routes/quoteSwap.test.ts index a59fc29780..54d31f0b39 100644 --- a/test/connectors/titan/router-routes/quoteSwap.test.ts +++ b/test/connectors/titan/router-routes/quoteSwap.test.ts @@ -2,6 +2,7 @@ import { Solana } from '../../../../src/chains/solana/solana'; import { Titan } from '../../../../src/connectors/titan/titan'; import { quoteCache } from '../../../../src/services/quote-cache'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/solana/solana'); jest.mock('../../../../src/connectors/titan/titan'); @@ -9,7 +10,7 @@ jest.mock('../../../../src/connectors/titan/titan'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/titan/router-routes/quoteSwap'); + const { quoteSwapRoute } = await import('../../../../src/trading/trading-router-routes/quoteSwap'); await server.register(quoteSwapRoute); return server; }; @@ -79,7 +80,8 @@ describe('GET /quote-swap (titan)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -90,12 +92,14 @@ describe('GET /quote-swap (titan)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId'); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut', 15); expect(body).toHaveProperty('price', 150); - expect(body).toHaveProperty('wallet', WALLET); + // `wallet` was a Titan-specific echo field; the unified router response carries + // the shared quote fields plus quoteId. The wallet is still what the quote was + // priced for — asserted below on the call itself. expect(body.approximation).toBeUndefined(); // The route was requested for the provided wallet @@ -134,7 +138,8 @@ describe('GET /quote-swap (titan)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -145,9 +150,9 @@ describe('GET /quote-swap (titan)', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('approximation', true); - expect(body).toHaveProperty('amountIn', 15); + expect(Number(body.amountIn)).toBe(15); expect(body.amountOut).toBeCloseTo(0.0999); expect(body.maxAmountIn).toBeCloseTo(15); expect(body.minAmountOut).toBeCloseTo(0.0999 * (1 - 0.005)); @@ -164,7 +169,8 @@ describe('GET /quote-swap (titan)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -176,7 +182,7 @@ describe('GET /quote-swap (titan)', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.message).toContain('ExactIn only'); expect(mockTitanInstance.getSwapRoute).not.toHaveBeenCalled(); }); @@ -192,7 +198,8 @@ describe('GET /quote-swap (titan)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', baseToken: 'INVALID', quoteToken: 'USDC', amount: '0.1', @@ -202,7 +209,7 @@ describe('GET /quote-swap (titan)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); it('should return 400 if no route found for SELL', async () => { @@ -216,7 +223,8 @@ describe('GET /quote-swap (titan)', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', + connector: 'titan', baseToken: 'SOL', quoteToken: 'USDC', amount: '0.1', @@ -226,7 +234,7 @@ describe('GET /quote-swap (titan)', () => { }); expect(response.statusCode).toBe(400); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body.message).toContain('No route found'); }); }); diff --git a/test/connectors/titan/schemas.test.ts b/test/connectors/titan/schemas.test.ts index 3d33aacfdf..f942690f8b 100644 --- a/test/connectors/titan/schemas.test.ts +++ b/test/connectors/titan/schemas.test.ts @@ -5,34 +5,6 @@ import * as Base from '../../../src/schemas/router-schema'; describe('Titan Schema Tests', () => { describe('Schema Superset Validation', () => { - it('TitanQuoteSwapRequest should be a superset of QuoteSwapRequest', () => { - const baseRequired = Base.QuoteSwapRequest.required || []; - const titanRequired = Titan.TitanQuoteSwapRequest.required || []; - - for (const field of baseRequired) { - expect(titanRequired).toContain(field); - } - - const baseProps = Object.keys(Base.QuoteSwapRequest.properties); - const titanProps = Object.keys(Titan.TitanQuoteSwapRequest.properties); - - for (const prop of baseProps) { - expect(titanProps).toContain(prop); - } - - const sampleRequest = { - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.QuoteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(Titan.TitanQuoteSwapRequest, sampleRequest)).toBe(true); - }); - it('TitanQuoteSwapResponse should be a superset of QuoteSwapResponse', () => { const baseRequired = Base.QuoteSwapResponse.required || []; const titanRequired = Titan.TitanQuoteSwapResponse.required || []; @@ -48,69 +20,9 @@ describe('Titan Schema Tests', () => { expect(titanProps).toContain(prop); } }); - - it('TitanExecuteQuoteRequest should be a superset of ExecuteQuoteRequest', () => { - const baseRequired = Base.ExecuteQuoteRequest.required || []; - const titanRequired = Titan.TitanExecuteQuoteRequest.required || []; - - for (const field of baseRequired) { - expect(titanRequired).toContain(field); - } - - const baseProps = Object.keys(Base.ExecuteQuoteRequest.properties); - const titanProps = Object.keys(Titan.TitanExecuteQuoteRequest.properties); - - for (const prop of baseProps) { - expect(titanProps).toContain(prop); - } - - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - quoteId: '123e4567-e89b-12d3-a456-426614174000', - }; - - expect(Value.Check(Base.ExecuteQuoteRequest, sampleRequest)).toBe(true); - expect(Value.Check(Titan.TitanExecuteQuoteRequest, sampleRequest)).toBe(true); - }); - - it('TitanExecuteSwapRequest should be a superset of ExecuteSwapRequest', () => { - const baseRequired = Base.ExecuteSwapRequest.required || []; - const titanRequired = Titan.TitanExecuteSwapRequest.required || []; - - for (const field of baseRequired) { - expect(titanRequired).toContain(field); - } - - const baseProps = Object.keys(Base.ExecuteSwapRequest.properties); - const titanProps = Object.keys(Titan.TitanExecuteSwapRequest.properties); - - for (const prop of baseProps) { - expect(titanProps).toContain(prop); - } - - const sampleRequest = { - walletAddress: '7aaee2311351ac9e4de53bf981fd3c882969e4edcd8e858b4eac50f6b8a41112', - network: 'mainnet-beta', - baseToken: 'SOL', - quoteToken: 'USDC', - amount: 1, - side: 'SELL', - slippagePct: 0.5, - }; - - expect(Value.Check(Base.ExecuteSwapRequest, sampleRequest)).toBe(true); - expect(Value.Check(Titan.TitanExecuteSwapRequest, sampleRequest)).toBe(true); - }); }); describe('Titan-specific Fields', () => { - it('TitanQuoteSwapRequest should include wallet binding and the BUY approximation flag', () => { - const props = Object.keys(Titan.TitanQuoteSwapRequest.properties); - expect(props).toContain('walletAddress'); - expect(props).toContain('approximateIfNoExactOut'); - }); - it('TitanQuoteSwapResponse should include the bound wallet', () => { const props = Object.keys(Titan.TitanQuoteSwapResponse.properties); expect(props).toContain('wallet'); @@ -118,11 +30,5 @@ describe('Titan Schema Tests', () => { }); }); - describe('Field Examples and Defaults', () => { - it('should have Solana mainnet-only network enum', () => { - const networkProp = Titan.TitanQuoteSwapRequest.properties.network; - expect(networkProp.default).toBe('mainnet-beta'); - expect(networkProp.enum).toEqual(['mainnet-beta']); - }); - }); + describe('Field Examples and Defaults', () => {}); }); diff --git a/test/connectors/titan/titan.routes.test.ts b/test/connectors/titan/titan.routes.test.ts deleted file mode 100644 index 76335a92a3..0000000000 --- a/test/connectors/titan/titan.routes.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('Titan Routes Structure', () => { - const CONNECTOR_NAME = 'titan'; - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should have appropriate route folders based on trading types', async () => { - const response = await fastify.inject({ - method: 'GET', - url: '/config/connectors', - }); - - const { connectors } = JSON.parse(response.body); - const titanConfig = connectors.find((c: any) => c.name === CONNECTOR_NAME); - - expect(titanConfig).toBeDefined(); - expect(titanConfig.chain).toBe('solana'); - expect(titanConfig.trading_types).toEqual(['router']); - expect(titanConfig.networks).toEqual(['mainnet-beta']); - - const connectorPath = path.join(__dirname, `../../../src/connectors/${CONNECTOR_NAME}`); - const routerRoutesPath = path.join(connectorPath, 'router-routes'); - expect(fs.existsSync(routerRoutesPath)).toBe(true); - - const files = fs.readdirSync(routerRoutesPath); - expect(files.some((f) => f.toLowerCase().includes('swap'))).toBe(true); - }); - }); - - describe('Route Registration', () => { - it('should register Titan router routes at /connectors/titan/router', async () => { - const routes = fastify.printRoutes(); - - expect(routes).toContain('titan/router/'); - expect(routes).toContain('quote-swap'); - expect(routes).toContain('execute-swap'); - }); - }); -}); diff --git a/test/connectors/uniswap/amm-routes/create-pool.test.ts b/test/connectors/uniswap/amm-routes/create-pool.test.ts index 4a42019083..edd34aae6f 100644 --- a/test/connectors/uniswap/amm-routes/create-pool.test.ts +++ b/test/connectors/uniswap/amm-routes/create-pool.test.ts @@ -11,7 +11,7 @@ const mockWallet = '0x0000000000000000000000000000000000000001'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/uniswap/amm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-amm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -44,7 +44,8 @@ describe('POST /create-pool (Uniswap V2)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'base', + chainNetwork: 'ethereum-base', + connector: 'uniswap', walletAddress: mockWallet, baseToken: 'ETH', quoteToken: 'WETH', diff --git a/test/connectors/uniswap/amm-routes/quote-swap.test.ts b/test/connectors/uniswap/amm-routes/quote-swap.test.ts index 8cda7e7cf5..1d2ff543f2 100644 --- a/test/connectors/uniswap/amm-routes/quote-swap.test.ts +++ b/test/connectors/uniswap/amm-routes/quote-swap.test.ts @@ -3,11 +3,18 @@ import { Token, TokenAmount, Pair } from '@uniswap/sdk'; import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; import { UniswapConfig } from '../../../../src/connectors/uniswap/uniswap.config'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); jest.mock('../../../../src/connectors/uniswap/uniswap.config'); jest.mock('../../../../src/connectors/uniswap/uniswap'); jest.mock('../../../../src/connectors/uniswap/uniswap.utils'); +// The unified amm route enters through quoteSwap(), which reads the pair's +// token0/token1 on-chain instead of taking quoteToken from the caller. +jest.mock('../../../../src/connectors/uniswap/amm-routes/poolTokens', () => ({ + resolveSwapPair: jest.fn(), + getAmmPoolTokens: jest.fn(), +})); // Mock ethers Contract globally jest.mock('ethers', () => { @@ -30,8 +37,8 @@ const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/uniswap/amm-routes/quoteSwap'); - await server.register(quoteSwapRoute); + const { makeQuoteSwapRoute } = await import('../../../../src/trading/pool-swap-routes'); + await server.register(makeQuoteSwapRoute('amm')); return server; }; @@ -106,6 +113,11 @@ describe('GET /quote-swap', () => { ready: jest.fn().mockReturnValue(true), init: jest.fn().mockResolvedValue(undefined), }; + const { resolveSwapPair } = require('../../../../src/connectors/uniswap/amm-routes/poolTokens'); + (resolveSwapPair as jest.Mock).mockResolvedValue({ + baseAddress: mockWETH.address, + quoteAddress: mockUSDC.address, + }); (Ethereum.getInstance as jest.Mock).mockResolvedValue(mockEthereumInstance); (Ethereum.getWalletAddressExample as jest.Mock).mockResolvedValue('0x1234567890123456789012345678901234567890'); @@ -187,7 +199,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', poolAddress: mockPoolAddress, baseToken: 'WETH', quoteToken: 'USDC', @@ -198,9 +211,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); - expect(body).toHaveProperty('amountIn', 0.1); + expect(Number(body.amountIn)).toBe(0.1); expect(body).toHaveProperty('amountOut'); expect(body).toHaveProperty('minAmountOut'); expect(body).toHaveProperty('maxAmountIn', 0.1); @@ -333,7 +346,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', poolAddress: mockPoolAddress, baseToken: 'WETH', quoteToken: 'USDC', @@ -344,10 +358,10 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('poolAddress', mockPoolAddress); expect(body).toHaveProperty('amountIn'); - expect(body).toHaveProperty('amountOut', 150); + expect(Number(body.amountOut)).toBe(150); expect(body).toHaveProperty('maxAmountIn'); expect(body).toHaveProperty('tokenIn', mockUSDC.address); expect(body).toHaveProperty('tokenOut', mockWETH.address); @@ -395,11 +409,19 @@ describe('GET /quote-swap', () => { }; (Ethereum.getInstance as jest.Mock).mockResolvedValue(mockEthereumInstance); + // On the unified route the base token is resolved against the pool, so an + // unresolvable token fails there rather than in a caller-supplied quoteToken. + const { resolveSwapPair } = require('../../../../src/connectors/uniswap/amm-routes/poolTokens'); + (resolveSwapPair as jest.Mock).mockRejectedValueOnce( + Object.assign(new Error('Token not found: INVALID'), { statusCode: 400 }), + ); + const response = await server.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', poolAddress: mockPoolAddress, baseToken: 'INVALID', quoteToken: 'USDC', @@ -410,6 +432,6 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(400); // Returns 400 for invalid token - expect(JSON.parse(response.body)).toHaveProperty('error'); + expect(parseWire(response.body)).toHaveProperty('error'); }); }); diff --git a/test/connectors/uniswap/amm-routes/remove-liquidity-confirmation.test.ts b/test/connectors/uniswap/amm-routes/remove-liquidity-confirmation.test.ts new file mode 100644 index 0000000000..c87e183fff --- /dev/null +++ b/test/connectors/uniswap/amm-routes/remove-liquidity-confirmation.test.ts @@ -0,0 +1,147 @@ +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Uniswap } from '../../../../src/connectors/uniswap/uniswap'; +import { getUniswapPoolInfo } from '../../../../src/connectors/uniswap/uniswap.utils'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/uniswap/uniswap'); +jest.mock('@ethersproject/contracts'); +jest.mock('../../../../src/connectors/uniswap/amm-routes/positionInfo', () => ({ + ...jest.requireActual('../../../../src/connectors/uniswap/amm-routes/positionInfo'), + checkLPAllowance: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../../../src/connectors/uniswap/uniswap.utils', () => ({ + ...jest.requireActual('../../../../src/connectors/uniswap/uniswap.utils'), + getUniswapPoolInfo: jest.fn(), +})); + +// The AMM side of the same confirmation contract (the CLMM side is pinned in +// clmm-routes/collect-fees-confirmation.test.ts). The AMM remove route is where the old +// behaviour was worst: `expectedBaseTokenAmount`/`expectedQuoteTokenAmount` are derived from +// reserves read BEFORE sending, so a reverted transaction used to report those withdrawals +// alongside status 0 — read downstream as "still pending" forever. + +const USDC = { address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', symbol: 'USDC', decimals: 6 }; +const DAI = { address: '0x50c5725949a6f0c72e6c4a641f24049a917db0cb', symbol: 'DAI', decimals: 18 }; +const mockWallet = '0x0000000000000000000000000000000000000001'; +const poolAddress = '0xd0b53d9277642d899df5c87a3966a349a798f224'; +const txHash = '0x3333333333333333333333333333333333333333333333333333333333333333'; + +const { Ethereum: RealEthereum } = jest.requireActual('../../../../src/chains/ethereum/ethereum'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { removeLiquidityRoute } = await import('../../../../src/trading/trading-amm-routes/remove'); + await server.register(removeLiquidityRoute); + return server; +}; + +const primeMocks = (receipt: any) => { + (getUniswapPoolInfo as jest.Mock).mockResolvedValue({ + baseTokenAddress: USDC.address, + quoteTokenAddress: DAI.address, + }); + + (Uniswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn((address: string) => Promise.resolve(address === USDC.address ? USDC : DAI)), + }); + + const ethereum: any = { + provider: {}, + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + prepareGasOptions: jest.fn().mockResolvedValue({}), + handleTransactionExecution: jest.fn().mockResolvedValue(receipt), + }; + ethereum.handleTransactionConfirmation = RealEthereum.prototype.handleTransactionConfirmation.bind(ethereum); + (Ethereum.getInstance as jest.Mock).mockResolvedValue(ethereum); + + const { Contract } = require('@ethersproject/contracts'); + (Contract as jest.Mock).mockImplementation(() => ({ + // LP pair reads: the wallet holds 10% of a pool with 100 USDC / 100 DAI of reserves, + // so removing 50% is worth 5 USDC + 5 DAI. + balanceOf: jest.fn().mockResolvedValue(BigNumber.from('1000000000000000000')), + token0: jest.fn().mockResolvedValue(USDC.address), + token1: jest.fn().mockResolvedValue(DAI.address), + totalSupply: jest.fn().mockResolvedValue(BigNumber.from('10000000000000000000')), + getReserves: jest.fn().mockResolvedValue([BigNumber.from('100000000'), BigNumber.from('100000000000000000000')]), + // Router write + removeLiquidity: jest.fn().mockResolvedValue({ hash: txHash }), + })); +}; + +const remove = (server: any) => + server.inject({ + method: 'POST', + url: '/remove', + payload: { + chainNetwork: 'ethereum-base', + connector: 'uniswap', + walletAddress: mockWallet, + poolAddress, + percentageToRemove: 50, + }, + }); + +describe('POST /remove (Uniswap V2 AMM) — transaction confirmation', () => { + let server: any; + + beforeEach(async () => { + jest.clearAllMocks(); + (Ethereum.getWalletAddressExample as jest.Mock) = jest.fn().mockResolvedValue(mockWallet); + server = await buildApp(); + }); + + afterEach(async () => { + await server.close(); + }); + + it('returns the pending shape with the tx hash and no withdrawn amounts', async () => { + primeMocks(null); + + const response = await remove(server); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(0); // TransactionStatus.PENDING + expect(body.data).toBeUndefined(); + }); + + it('fails loudly (400 TRANSACTION_FAILED) on a revert rather than booking amounts as pending', async () => { + primeMocks({ + status: 0, + transactionHash: txHash, + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await remove(server); + + expect(response.statusCode).toBe(400); + expect(parseWire(response.body).message).toContain(txHash); + expect(response.body).not.toContain('baseTokenAmountRemoved'); + }); + + it('returns the confirmed shape with the withdrawn amounts and the receipt gas fee', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [], + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await remove(server); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body.status).toBe(1); // TransactionStatus.CONFIRMED + expect(Number(body.data.baseTokenAmountRemoved)).toBe(5); + expect(body.data.quoteTokenAmountRemoved).toBe(5); + expect(body.data.fee).toBe(0.000021); + }); +}); diff --git a/test/connectors/uniswap/amm.test.js b/test/connectors/uniswap/amm.test.js deleted file mode 100644 index 3a9bb57c24..0000000000 --- a/test/connectors/uniswap/amm.test.js +++ /dev/null @@ -1,540 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'uniswap'; -const PROTOCOL = 'amm'; -const NETWORK = 'base'; // Only test Base network -const BASE_TOKEN = 'WETH'; -const QUOTE_TOKEN = 'USDC'; -const TEST_POOL = '0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c'; // WETH-USDC on Base -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - // Use mocks from the same directory - const filePath = path.join(__dirname, 'mocks', `${PROTOCOL}-${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate pool info response structure -function validatePoolInfo(response) { - return ( - response && - typeof response.address === 'string' && - typeof response.baseTokenAddress === 'string' && - typeof response.quoteTokenAddress === 'string' && - typeof response.feePct === 'number' && - typeof response.price === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - response.poolType === 'amm' && - response.lpMint && - typeof response.lpMint.address === 'string' && - typeof response.lpMint.decimals === 'number' - ); -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' - ); -} - -// Tests -describe('Uniswap AMM Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Pool Info Endpoint', () => { - test('returns and validates pool info', async () => { - // Load mock response - const mockResponse = loadMockResponse('pool-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePoolInfo(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.address).toBe(TEST_POOL); - expect(response.data.poolType).toBe('amm'); - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }), - }), - ); - }); - - test('handles error for non-existent pool', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'Pool not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: 'UNKNOWN', - quoteToken: QUOTE_TOKEN, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'Pool not found', - }, - }, - }); - }); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('quote-swap'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Modify the mock response for BUY direction - const mockSellResponse = loadMockResponse('quote-swap'); - const mockBuyResponse = { - ...mockSellResponse, - // Flip the values for BUY direction - estimatedAmountIn: mockSellResponse.estimatedAmountOut, - estimatedAmountOut: mockSellResponse.estimatedAmountIn, - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.quoteTokenBalanceChange, // Negative for BUY - // For BUY: price = quote needed / base received - price: mockSellResponse.estimatedAmountOut / mockSellResponse.estimatedAmountIn, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Mock a quote-swap response to use as input for execute-swap - const quoteResponse = loadMockResponse('quote-swap'); - - // Mock a successful execution response - const executeResponse = { - signature: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - amountIn: quoteResponse.estimatedAmountIn, - amountOut: quoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: quoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: quoteResponse.quoteTokenBalanceChange, - }; - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(quoteResponse.estimatedAmountIn); - expect(response.data.amountOut).toBe(quoteResponse.estimatedAmountOut); - }); - - test('handles transaction simulation error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 500, - data: { - error: 'InternalServerError', - message: 'Transaction simulation failed', - code: 500, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1000000.0, - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 500, - data: { - error: 'InternalServerError', - }, - }, - }); - }); - }); - - describe('Quote Liquidity Endpoint', () => { - test('returns and validates liquidity quote', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - baseTokenLiquidity: 1.0, - quoteTokenLiquidity: 2340.5, - lpTokenAmount: 54.32, - shareOfPool: 0.0001, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-liquidity`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.lpTokenAmount).toBeGreaterThan(0); - expect(response.data.shareOfPool).toBeGreaterThan(0); - }); - - test('handles imbalanced liquidity error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Token amounts do not match pool ratio', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-liquidity`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 100.0, // Wrong ratio - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('ratio'), - }, - }, - }); - }); - }); - - describe('Position Info Endpoint', () => { - test('returns and validates position info', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - positionId: 'uniswap-v2-lp-123456', - lpTokenAmount: 100.5, - baseTokenAmount: 1.85, - quoteTokenAmount: 4329.225, - shareOfPool: 0.01, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 100.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.lpTokenAmount).toBe(100.5); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - }); - }); - - describe('Add Liquidity Endpoint', () => { - test('returns successful liquidity addition', async () => { - const mockResponse = { - signature: '0xabcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab', - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - lpTokenAmount: 54.32, - poolAddress: TEST_POOL, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.lpTokenAmount).toBeGreaterThan(0); - expect(response.data.baseTokenAmount).toBe(1.0); - expect(response.data.quoteTokenAmount).toBe(2340.5); - }); - - test('handles insufficient balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient balance for WETH', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - baseTokenAmount: 10000.0, // Large amount - quoteTokenAmount: 23405000.0, - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient balance'), - }, - }, - }); - }); - }); - - describe('Remove Liquidity Endpoint', () => { - test('returns successful liquidity removal', async () => { - const mockResponse = { - signature: '0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcd12', - baseTokenAmount: 0.95, - quoteTokenAmount: 2223.475, - lpTokenAmount: 54.32, - poolAddress: TEST_POOL, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 54.32, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - expect(response.data.lpTokenAmount).toBe(54.32); - }); - - test('handles insufficient LP token balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient LP token balance', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - poolAddress: TEST_POOL, - lpTokenAmount: 10000.0, // Large amount - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient LP token balance'), - }, - }, - }); - }); - }); -}); diff --git a/test/connectors/uniswap/clmm-routes/collect-fees-confirmation.test.ts b/test/connectors/uniswap/clmm-routes/collect-fees-confirmation.test.ts new file mode 100644 index 0000000000..f340487d98 --- /dev/null +++ b/test/connectors/uniswap/clmm-routes/collect-fees-confirmation.test.ts @@ -0,0 +1,147 @@ +import { Token } from '@uniswap/sdk-core'; +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Uniswap } from '../../../../src/connectors/uniswap/uniswap'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/uniswap/uniswap'); +jest.mock('@ethersproject/contracts'); + +// Route-level contract for an EVM CLMM liquidity route once the transaction has been sent. +// collect-fees stands in for every route on the shared confirmation gate: the amounts in its +// `data` are read BEFORE sending, so reporting them for anything but a confirmed transaction +// books tokens that never moved. +// +// Pinned here: +// - still pending -> 200 { signature, status: 0 } and NO data (used to be a TypeError on a +// null receipt, turned into a 500 that threw the tx hash away). +// - reverted -> 400 TRANSACTION_FAILED with no amounts (used to be status 0 == PENDING +// alongside the pre-send fee amounts, which a poller waits on forever). +// - confirmed -> 200 { status: 1, data } with the gas fee from the receipt. + +const WETH = new Token(8453, '0x4200000000000000000000000000000000000006', 18, 'WETH', 'Wrapped Ether'); +const USDC = new Token(8453, '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', 6, 'USDC', 'USD Coin'); +const mockWallet = '0x0000000000000000000000000000000000000001'; +const positionAddress = '1234'; +const txHash = '0x1111111111111111111111111111111111111111111111111111111111111111'; + +// The real confirmation helper, bound to the stubbed instance, so these tests exercise the +// gate itself rather than a mock of it. +const { Ethereum: RealEthereum } = jest.requireActual('../../../../src/chains/ethereum/ethereum'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { collectFeesRoute } = await import('../../../../src/trading/trading-clmm-routes/collect-fees'); + await server.register(collectFeesRoute); + return server; +}; + +/** Wire the Uniswap/Ethereum/Contract stubs so the route reaches the confirmation gate. */ +const primeMocks = (receipt: any) => { + (Uniswap.getInstance as jest.Mock).mockResolvedValue({ + checkNFTOwnership: jest.fn().mockResolvedValue(undefined), + getToken: jest.fn((address: string) => + Promise.resolve(address.toLowerCase() === WETH.address.toLowerCase() ? WETH : USDC), + ), + }); + + const ethereum: any = { + provider: {}, + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + prepareGasOptions: jest.fn().mockResolvedValue({}), + handleTransactionExecution: jest.fn().mockResolvedValue(receipt), + }; + ethereum.handleTransactionConfirmation = RealEthereum.prototype.handleTransactionConfirmation.bind(ethereum); + (Ethereum.getInstance as jest.Mock).mockResolvedValue(ethereum); + (Ethereum.getWalletAddressExample as jest.Mock).mockResolvedValue(mockWallet); + + const { Contract } = require('@ethersproject/contracts'); + (Contract as jest.Mock).mockImplementation(() => ({ + positions: jest.fn().mockResolvedValue({ + token0: WETH.address, + token1: USDC.address, + // The real positions() struct always carries the fee tier; collect-fees now reads + // it to derive the pool it acted on for the response. + fee: 3000, + tokensOwed0: BigNumber.from('1000000000000000'), // 0.001 WETH + tokensOwed1: BigNumber.from('2000000'), // 2 USDC + }), + multicall: jest.fn().mockResolvedValue({ hash: txHash }), + })); +}; + +const collect = (server: any) => + server.inject({ + method: 'POST', + url: '/collect-fees', + payload: { chainNetwork: 'ethereum-base', connector: 'uniswap', walletAddress: mockWallet, positionAddress }, + }); + +describe('POST /collect-fees (Uniswap V3 CLMM) — transaction confirmation', () => { + let server: any; + + beforeEach(async () => { + jest.clearAllMocks(); + (Ethereum.getWalletAddressExample as jest.Mock) = jest.fn().mockResolvedValue(mockWallet); + server = await buildApp(); + }); + + afterEach(async () => { + await server.close(); + }); + + it('returns the pending shape with the tx hash when the receipt is still missing', async () => { + primeMocks(null); + + const response = await collect(server); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(0); // TransactionStatus.PENDING + // No fabricated amounts: nothing has been collected yet. + expect(body.data).toBeUndefined(); + }); + + it('fails loudly (400 TRANSACTION_FAILED) on a revert instead of reporting PENDING with amounts', async () => { + primeMocks({ + status: 0, + transactionHash: txHash, + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await collect(server); + + expect(response.statusCode).toBe(400); + const body = parseWire(response.body); + expect(body.message).toContain(txHash); + expect(body.message).toMatch(/reverted on-chain/); + // The pre-send fee amounts must not appear anywhere in the response. + expect(response.body).not.toContain('baseFeeAmountCollected'); + }); + + it('returns the confirmed shape with the receipt gas fee', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [], + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await collect(server); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(1); // TransactionStatus.CONFIRMED + expect(Number(body.data.fee)).toBe(0.000021); + expect(body.data.baseFeeAmountCollected).toBe(0.001); + expect(body.data.quoteFeeAmountCollected).toBe(2); + }); +}); diff --git a/test/connectors/uniswap/clmm-routes/create-pool.test.ts b/test/connectors/uniswap/clmm-routes/create-pool.test.ts index 8eebf6fe82..2647e9875e 100644 --- a/test/connectors/uniswap/clmm-routes/create-pool.test.ts +++ b/test/connectors/uniswap/clmm-routes/create-pool.test.ts @@ -10,7 +10,7 @@ const mockWallet = '0x0000000000000000000000000000000000000001'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { createPoolRoute } = await import('../../../../src/connectors/uniswap/clmm-routes/createPool'); + const { createPoolRoute } = await import('../../../../src/trading/trading-clmm-routes/create-pool'); await server.register(createPoolRoute); return server; }; @@ -44,16 +44,19 @@ describe('POST /create-pool (Uniswap V3 CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'base', + chainNetwork: 'ethereum-base', + connector: 'uniswap', walletAddress: mockWallet, baseToken: 'WETH', quoteToken: 'USDC', - fee: 1234, // not one of 100 / 500 / 3000 / 10000 + // The unified route takes the V3 fee tier as feeBps (basis points) and + // multiplies by 100; 7 bps is not one of 1 / 5 / 30 / 100. + feeBps: 7, initialPrice: 3000, }, }); - // Fastify schema validation rejects the out-of-enum fee before the handler runs → 400. + // The connector rejects an unsupported tier with a 400. expect(response.statusCode).toBe(400); }); @@ -62,11 +65,13 @@ describe('POST /create-pool (Uniswap V3 CLMM)', () => { method: 'POST', url: '/create-pool', payload: { - network: 'base', + chainNetwork: 'ethereum-base', + // The unified route requires the V3 fee tier explicitly. + feeBps: 30, + connector: 'uniswap', walletAddress: mockWallet, baseToken: 'WETH', quoteToken: 'WETH', - fee: 3000, initialPrice: 3000, }, }); diff --git a/test/connectors/uniswap/clmm-routes/open-position-confirmation.test.ts b/test/connectors/uniswap/clmm-routes/open-position-confirmation.test.ts new file mode 100644 index 0000000000..f2a5e1d9bf --- /dev/null +++ b/test/connectors/uniswap/clmm-routes/open-position-confirmation.test.ts @@ -0,0 +1,173 @@ +import { Token } from '@uniswap/sdk-core'; +import { Pool, TickMath, encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Uniswap } from '../../../../src/connectors/uniswap/uniswap'; +import { getUniswapV3NftManagerAddress } from '../../../../src/connectors/uniswap/uniswap.contracts'; +import { getUniswapPoolInfo } from '../../../../src/connectors/uniswap/uniswap.utils'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/uniswap/uniswap'); +jest.mock('@ethersproject/contracts'); +jest.mock('../../../../src/connectors/uniswap/uniswap.utils', () => ({ + ...jest.requireActual('../../../../src/connectors/uniswap/uniswap.utils'), + getUniswapPoolInfo: jest.fn(), +})); + +// open-position is the route where a confirmed transaction can still fail to produce a usable +// result: the position's address only exists in the NFT-mint Transfer log. Pinned here: +// - still pending -> 200 { signature, status: 0 } with no data (no mint log yet). +// - confirmed, no mint log -> 500 naming the tx hash, NOT a confirmed response carrying +// positionAddress: '' that the caller could never address. +// - confirmed, mint log found -> 200 with the position ID from the log. +// - unexpected failure -> the underlying message survives the route's catch. + +const WETH = new Token(8453, '0x4200000000000000000000000000000000000006', 18, 'WETH', 'Wrapped Ether'); +const USDC = new Token(8453, '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', 6, 'USDC', 'USD Coin'); +const mockWallet = '0x0000000000000000000000000000000000000001'; +const poolAddress = '0xd0b53D9277642d899DF5C87A3966A349A798F224'; +const txHash = '0x2222222222222222222222222222222222222222222222222222222222222222'; +const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; +const ZERO_TOPIC = '0x0000000000000000000000000000000000000000000000000000000000000000'; + +const { Ethereum: RealEthereum } = jest.requireActual('../../../../src/chains/ethereum/ethereum'); + +/** A live WETH/USDC 0.3% pool at ~3000 USDC per WETH. */ +const buildPool = () => { + const sqrtRatioX96 = encodeSqrtRatioX96('3000000000', '1000000000000000000'); + return new Pool(WETH, USDC, 3000, sqrtRatioX96, '1000000000000', TickMath.getTickAtSqrtRatio(sqrtRatioX96)); +}; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { openPositionRoute } = await import('../../../../src/trading/trading-clmm-routes/open'); + await server.register(openPositionRoute); + return server; +}; + +const primeMocks = (receipt: any, uniswapOverrides: Record = {}) => { + (getUniswapPoolInfo as jest.Mock).mockResolvedValue({ + baseTokenAddress: WETH.address, + quoteTokenAddress: USDC.address, + }); + + (Uniswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn((address: string) => + Promise.resolve(address.toLowerCase() === WETH.address.toLowerCase() ? WETH : USDC), + ), + getV3Pool: jest.fn().mockResolvedValue(buildPool()), + ...uniswapOverrides, + }); + + const ethereum: any = { + provider: {}, + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + getContract: jest.fn().mockReturnValue({}), + getERC20Allowance: jest.fn().mockResolvedValue({ value: BigNumber.from('1000000000000000000000000') }), + prepareGasOptions: jest.fn().mockResolvedValue({}), + handleTransactionExecution: jest.fn().mockResolvedValue(receipt), + }; + ethereum.handleTransactionConfirmation = RealEthereum.prototype.handleTransactionConfirmation.bind(ethereum); + (Ethereum.getInstance as jest.Mock).mockResolvedValue(ethereum); + + const { Contract } = require('@ethersproject/contracts'); + (Contract as jest.Mock).mockImplementation(() => ({ + multicall: jest.fn().mockResolvedValue({ hash: txHash }), + })); +}; + +const open = (server: any) => + server.inject({ + method: 'POST', + url: '/open', + payload: { + chainNetwork: 'ethereum-base', + connector: 'uniswap', + walletAddress: mockWallet, + poolAddress, + lowerPrice: 2500, + upperPrice: 3500, + baseTokenAmount: 0.1, + quoteTokenAmount: 300, + }, + }); + +describe('POST /open-position (Uniswap V3 CLMM) — transaction confirmation', () => { + let server: any; + + beforeEach(async () => { + jest.clearAllMocks(); + (Ethereum.getWalletAddressExample as jest.Mock) = jest.fn().mockResolvedValue(mockWallet); + server = await buildApp(); + }); + + afterEach(async () => { + await server.close(); + }); + + it('returns the pending shape with the tx hash when the receipt is still missing', async () => { + primeMocks(null); + + const response = await open(server); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(0); // TransactionStatus.PENDING + expect(body.data).toBeUndefined(); + }); + + it('fails loudly naming the tx hash when the confirmed transaction has no NFT mint log', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [], // no Transfer-from-zero log -> the position ID is unknowable + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await open(server); + + expect(response.statusCode).toBe(500); + const body = parseWire(response.body); + expect(body.message).toContain(txHash); + // Never a confirmed response with an unusable position address. + expect(response.body).not.toContain('positionAddress'); + }); + + it('returns the position ID read from the NFT mint log on confirmation', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [ + { + address: getUniswapV3NftManagerAddress('base'), + topics: [TRANSFER_TOPIC, ZERO_TOPIC, ZERO_TOPIC, BigNumber.from(987654).toHexString()], + }, + ], + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await open(server); + + expect(response.statusCode).toBe(200); + const body = parseWire(response.body); + expect(body.status).toBe(1); // TransactionStatus.CONFIRMED + expect(body.data.positionAddress).toBe('987654'); + expect(Number(body.data.fee)).toBe(0.000021); + }); + + it('keeps the underlying error message instead of a bare "Failed to open position"', async () => { + primeMocks(null, { getV3Pool: jest.fn().mockRejectedValue(new Error('pool state fetch exploded')) }); + + const response = await open(server); + + expect(response.statusCode).toBe(500); + expect(parseWire(response.body).message).toContain('pool state fetch exploded'); + }); +}); diff --git a/test/connectors/uniswap/clmm-routes/pool-info.test.ts b/test/connectors/uniswap/clmm-routes/pool-info.test.ts index 762156a905..8fc7bb9054 100644 --- a/test/connectors/uniswap/clmm-routes/pool-info.test.ts +++ b/test/connectors/uniswap/clmm-routes/pool-info.test.ts @@ -2,6 +2,7 @@ import { BigNumber } from 'ethers'; import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); jest.mock('../../../../src/connectors/uniswap/uniswap'); @@ -10,8 +11,8 @@ jest.mock('../../../../src/connectors/uniswap/uniswap.utils'); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { poolInfoRoute } = await import('../../../../src/connectors/uniswap/clmm-routes/poolInfo'); - await server.register(poolInfoRoute); + const { poolsRoute } = await import('../../../../src/trading/clmm/pools'); + await server.register(poolsRoute); return server; }; @@ -116,17 +117,17 @@ describe('GET /pool-info (Uniswap CLMM)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet', poolAddress: POOL_ADDRESS }, + query: { chainNetwork: 'ethereum-mainnet', connector: 'uniswap', poolAddress: POOL_ADDRESS }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // Pool metadata still surfaced from the V3 pool object. expect(body.address).toBe(POOL_ADDRESS); expect(body.baseTokenAddress).toBe(USDM1.address); expect(body.quoteTokenAddress).toBe(USDC.address); - expect(body.feePct).toBeCloseTo(0.01, 6); + expect(Number(body.feePct)).toBeCloseTo(0.01, 6); expect(body.binStep).toBe(1); expect(body.activeBinId).toBe(-276211); expect(body.price).toBeCloseTo(1.01146, 4); @@ -192,14 +193,14 @@ describe('GET /pool-info (Uniswap CLMM)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet', poolAddress: POOL_ADDRESS }, + query: { chainNetwork: 'ethereum-mainnet', connector: 'uniswap', poolAddress: POOL_ADDRESS }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // Base is USDC now → base amount should be the USDC balance. - expect(body.baseTokenAmount).toBeCloseTo(182100, 0); + expect(Number(body.baseTokenAmount)).toBeCloseTo(182100, 0); expect(body.quoteTokenAmount).toBeCloseTo(167600, 0); // Price flips correspondingly (USDC per USDM1 was 1.01146; USDM1 per USDC ≈ 0.98867). expect(body.price).toBeCloseTo(0.98867, 4); @@ -264,10 +265,10 @@ describe('GET /pool-info (Uniswap CLMM)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet', poolAddress: POOL_ADDRESS }, + query: { chainNetwork: 'ethereum-mainnet', connector: 'uniswap', poolAddress: POOL_ADDRESS }, }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body).bins).toBeUndefined(); + expect(parseWire(response.body).bins).toBeUndefined(); expect(computeUniswapBinDistribution).not.toHaveBeenCalled(); }); @@ -277,10 +278,10 @@ describe('GET /pool-info (Uniswap CLMM)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet', poolAddress: POOL_ADDRESS, binCount: 0 }, + query: { chainNetwork: 'ethereum-mainnet', connector: 'uniswap', poolAddress: POOL_ADDRESS, binCount: 0 }, }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body).bins).toBeUndefined(); + expect(parseWire(response.body).bins).toBeUndefined(); expect(computeUniswapBinDistribution).not.toHaveBeenCalled(); }); @@ -291,10 +292,10 @@ describe('GET /pool-info (Uniswap CLMM)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet', poolAddress: POOL_ADDRESS, binCount: 11 }, + query: { chainNetwork: 'ethereum-mainnet', connector: 'uniswap', poolAddress: POOL_ADDRESS, binCount: 11 }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(Array.isArray(body.bins)).toBe(true); expect(body.bins).toHaveLength(11); expect(body.bins[0]).toEqual( @@ -317,7 +318,7 @@ describe('GET /pool-info (Uniswap CLMM)', () => { const response = await server.inject({ method: 'GET', url: '/pool-info', - query: { network: 'mainnet', poolAddress: POOL_ADDRESS, binCount: 999 }, + query: { chainNetwork: 'ethereum-mainnet', connector: 'uniswap', poolAddress: POOL_ADDRESS, binCount: 999 }, }); expect(response.statusCode).toBe(400); }); diff --git a/test/connectors/uniswap/clmm.test.js b/test/connectors/uniswap/clmm.test.js deleted file mode 100644 index 8890dc3fcc..0000000000 --- a/test/connectors/uniswap/clmm.test.js +++ /dev/null @@ -1,752 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'uniswap'; -const PROTOCOL = 'clmm'; -const NETWORK = 'base'; // Only test Base network -const BASE_TOKEN = 'WETH'; -const QUOTE_TOKEN = 'USDC'; -const TEST_POOL = '0xd0b53d9277642d899df5c87a3966a349a798f224'; // WETH-USDC on Base -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - // Use mocks from the same directory - const filePath = path.join(__dirname, 'mocks', `${PROTOCOL}-${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -// Function to validate pool info response structure -function validatePoolInfo(response) { - return ( - response && - typeof response.address === 'string' && - typeof response.baseTokenAddress === 'string' && - typeof response.quoteTokenAddress === 'string' && - typeof response.feePct === 'number' && - typeof response.price === 'number' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' - ); -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' && - typeof response.computeUnits === 'number' // Added computeUnits - ); -} - -// Function to validate position info response structure -function validatePositionInfo(response) { - return ( - response && - typeof response.poolAddress === 'string' && - typeof response.positionId === 'string' && - typeof response.lowerTick === 'number' && - typeof response.upperTick === 'number' && - typeof response.liquidity === 'string' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.unclaimedFeeBaseAmount === 'number' && - typeof response.unclaimedFeeQuoteAmount === 'number' - ); -} - -// Function to validate quote position response -function validateQuotePosition(response) { - return ( - response && - typeof response.baseLimited === 'boolean' && - typeof response.baseTokenAmount === 'number' && - typeof response.quoteTokenAmount === 'number' && - typeof response.baseTokenAmountMax === 'number' && - typeof response.quoteTokenAmountMax === 'number' && - response.liquidity !== undefined && // Can be string or object - typeof response.computeUnits === 'number' // Added computeUnits - ); -} - -// Function to validate open position response -function validateOpenPosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.positionId === 'string' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate add liquidity response -function validateAddLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountAdded === 'number' && - typeof response.data.quoteTokenAmountAdded === 'number')) - ); -} - -// Function to validate remove liquidity response -function validateRemoveLiquidity(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number')) - ); -} - -// Function to validate close position response -function validateClosePosition(response) { - return ( - response && - typeof response.signature === 'string' && - typeof response.status === 'number' && - (response.status !== 1 || // If not CONFIRMED - (response.data && - typeof response.data.fee === 'number' && - typeof response.data.baseTokenAmountRemoved === 'number' && - typeof response.data.quoteTokenAmountRemoved === 'number' && - typeof response.data.baseFeeAmountCollected === 'number' && - typeof response.data.quoteFeeAmountCollected === 'number')) - ); -} - -// Tests -describe('Uniswap CLMM Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Pool Info Endpoint', () => { - test('returns and validates pool info', async () => { - // Load mock response - const mockResponse = loadMockResponse('pool-info'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validatePoolInfo(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.address).toBe(TEST_POOL); - expect(response.data.feePct).toBe(0.05); // 0.05% fee for CLMM - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - }), - }), - ); - }); - - test('handles error for non-existent pool', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'Pool not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/pool-info`, { - params: { - network: NETWORK, - baseToken: 'UNKNOWN', - quoteToken: QUOTE_TOKEN, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'Pool not found', - }, - }, - }); - }); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Load mock response - const mockResponse = loadMockResponse('quote-swap'); - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Modify the mock response for BUY direction - const mockSellResponse = loadMockResponse('quote-swap'); - const mockBuyResponse = { - ...mockSellResponse, - // Flip the values for BUY direction - estimatedAmountIn: mockSellResponse.estimatedAmountOut, - estimatedAmountOut: mockSellResponse.estimatedAmountIn, - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.quoteTokenBalanceChange, // Negative for BUY - // For BUY: price = quote needed / base received - price: mockSellResponse.estimatedAmountOut / mockSellResponse.estimatedAmountIn, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values - expect(response.data.poolAddress).toBe(TEST_POOL); - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution', async () => { - // Mock a quote-swap response to use as input for execute-swap - const quoteResponse = loadMockResponse('quote-swap'); - - // Mock a successful execution response - const executeResponse = { - signature: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - amountIn: quoteResponse.estimatedAmountIn, - amountOut: quoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: quoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: quoteResponse.quoteTokenBalanceChange, - }; - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(quoteResponse.estimatedAmountIn); - expect(response.data.amountOut).toBe(quoteResponse.estimatedAmountOut); - }); - }); - - describe('Position Info Endpoint', () => { - test('returns and validates position info', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - positionId: '123456', - lowerTick: -887272, - upperTick: 887272, - liquidity: '1000000000000000000', - baseTokenAmount: 1.5, - quoteTokenAmount: 3510.75, - unclaimedFeeBaseAmount: 0.001, - unclaimedFeeQuoteAmount: 2.34, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: '123456', - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.positionId).toBe('123456'); - expect(response.data.liquidity).toBeDefined(); - expect(response.data.unclaimedFeeBaseAmount).toBeGreaterThanOrEqual(0); - expect(response.data.unclaimedFeeQuoteAmount).toBeGreaterThanOrEqual(0); - }); - - test('handles position not found error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 404, - data: { - error: 'NotFound', - message: 'Position not found', - code: 404, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/position-info`, { - params: { - network: NETWORK, - positionId: 'invalid-position', - }, - }), - ).rejects.toMatchObject({ - response: { - status: 404, - data: { - error: 'NotFound', - }, - }, - }); - }); - }); - - describe('Positions Owned Endpoint', () => { - test('returns list of owned positions', async () => { - const mockResponse = [ - { - poolAddress: TEST_POOL, - positionId: '123456', - lowerTick: -887272, - upperTick: 887272, - liquidity: '1000000000000000000', - baseTokenAmount: 1.5, - quoteTokenAmount: 3510.75, - unclaimedFeeBaseAmount: 0.001, - unclaimedFeeQuoteAmount: 2.34, - }, - { - poolAddress: TEST_POOL, - positionId: '789012', - lowerTick: -443636, - upperTick: 443636, - liquidity: '500000000000000000', - baseTokenAmount: 0.75, - quoteTokenAmount: 1755.375, - unclaimedFeeBaseAmount: 0.0005, - unclaimedFeeQuoteAmount: 1.17, - }, - ]; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/positions-owned`, { - params: { - network: NETWORK, - wallet: TEST_WALLET, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(Array.isArray(response.data)).toBe(true); - expect(response.data.length).toBe(2); - expect(response.data[0].positionId).toBe('123456'); - expect(response.data[1].positionId).toBe('789012'); - }); - }); - - describe('Quote Position Endpoint', () => { - test('returns and validates quote for new position', async () => { - const mockResponse = { - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - liquidity: '680000000000000000', - shareOfPool: 0.0001, - }; - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.liquidity).toBeDefined(); - expect(response.data.shareOfPool).toBeGreaterThan(0); - }); - - test('handles invalid tick range error', async () => { - // Setup mock axios with error response - axios.get.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Invalid tick range', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.get(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/quote-position`, { - params: { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: 100, - upperTick: 50, // Invalid: upper < lower - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - }, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Invalid tick range'), - }, - }, - }); - }); - }); - - describe('Open Position Endpoint', () => { - test('returns successful position opening', async () => { - const mockResponse = { - signature: '0xabcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab', - positionId: '345678', - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - liquidity: '680000000000000000', - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 1.0, - quoteTokenAmount: 2340.5, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBeDefined(); - expect(response.data.liquidity).toBeDefined(); - }); - - test('handles insufficient balance error', async () => { - // Setup mock axios with error response - axios.post.mockRejectedValueOnce({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: 'Insufficient balance for WETH', - code: 400, - }, - }, - }); - - // Make the request and expect it to be rejected - await expect( - axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/open-position`, { - network: NETWORK, - poolAddress: TEST_POOL, - lowerTick: -887272, - upperTick: 887272, - baseTokenAmount: 10000.0, // Large amount - quoteTokenAmount: 23405000.0, - wallet: TEST_WALLET, - }), - ).rejects.toMatchObject({ - response: { - status: 400, - data: { - error: 'BadRequest', - message: expect.stringContaining('Insufficient balance'), - }, - }, - }); - }); - }); - - describe('Add Liquidity Endpoint', () => { - test('returns successful liquidity addition to existing position', async () => { - const mockResponse = { - signature: '0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcd12', - positionId: '123456', - liquidity: '340000000000000000', - baseTokenAmount: 0.5, - quoteTokenAmount: 1170.25, - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/add-liquidity`, { - network: NETWORK, - positionId: '123456', - baseTokenAmount: 0.5, - quoteTokenAmount: 1170.25, - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe('123456'); - expect(response.data.liquidity).toBeDefined(); - }); - }); - - describe('Remove Liquidity Endpoint', () => { - test('returns successful liquidity removal', async () => { - const mockResponse = { - signature: '0x1234abcd5678efgh1234abcd5678efgh1234abcd5678efgh1234abcd5678efgh', - positionId: '123456', - baseTokenAmount: 0.75, - quoteTokenAmount: 1755.375, - liquidityRemoved: '500000000000000000', - fee: 0.003, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/remove-liquidity`, { - network: NETWORK, - positionId: '123456', - liquidity: '500000000000000000', - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - expect(response.data.liquidityRemoved).toBe('500000000000000000'); - }); - }); - - describe('Close Position Endpoint', () => { - test('returns successful position closure', async () => { - const mockResponse = { - signature: '0xaaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888', - positionId: '123456', - baseTokenAmount: 1.5, - quoteTokenAmount: 3510.75, - feeBaseAmount: 0.001, - feeQuoteAmount: 2.34, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/close-position`, { - network: NETWORK, - positionId: '123456', - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.positionId).toBe('123456'); - expect(response.data.baseTokenAmount).toBeGreaterThan(0); - expect(response.data.quoteTokenAmount).toBeGreaterThan(0); - }); - }); - - describe('Collect Fees Endpoint', () => { - test('returns successful fee collection', async () => { - const mockResponse = { - signature: '0x9999888877776666555544443333222211110000aaaabbbbccccddddeeeeffff', - positionId: '123456', - feeBaseAmount: 0.001, - feeQuoteAmount: 2.34, - }; - - // Setup mock axios - axios.post.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/${PROTOCOL}/collect-fees`, { - network: NETWORK, - positionId: '123456', - wallet: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.feeBaseAmount).toBeGreaterThanOrEqual(0); - expect(response.data.feeQuoteAmount).toBeGreaterThanOrEqual(0); - }); - }); -}); diff --git a/test/connectors/uniswap/mocks/amm-pool-info-invalid.json b/test/connectors/uniswap/mocks/amm-pool-info-invalid.json deleted file mode 100644 index efea079c7a..0000000000 --- a/test/connectors/uniswap/mocks/amm-pool-info-invalid.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "Internal Server Error", - "message": "An unexpected error occurred" -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/amm-pool-info.json b/test/connectors/uniswap/mocks/amm-pool-info.json deleted file mode 100644 index 94916b1bb2..0000000000 --- a/test/connectors/uniswap/mocks/amm-pool-info.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "address": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "baseTokenAddress": "0x4200000000000000000000000000000000000006", - "quoteTokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "feePct": 0.3, - "price": 2200.50, - "baseTokenAmount": 80.5, - "quoteTokenAmount": 177150.25, - "poolType": "amm", - "lpMint": { - "address": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "decimals": 18 - } -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/amm-quote-liquidity-imbalanced.json b/test/connectors/uniswap/mocks/amm-quote-liquidity-imbalanced.json deleted file mode 100644 index 2572d620c2..0000000000 --- a/test/connectors/uniswap/mocks/amm-quote-liquidity-imbalanced.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 500, - "error": "InternalServerError", - "message": "Failed to get liquidity quote" -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/amm-quote-swap-invalid-token.json b/test/connectors/uniswap/mocks/amm-quote-swap-invalid-token.json deleted file mode 100644 index 3f82724284..0000000000 --- a/test/connectors/uniswap/mocks/amm-quote-swap-invalid-token.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "statusCode": 400, - "error": "BadRequestError", - "message": "Base token not found: INVALID" -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/amm-quote-swap-sell.json b/test/connectors/uniswap/mocks/amm-quote-swap-sell.json deleted file mode 100644 index 9e17c6431e..0000000000 --- a/test/connectors/uniswap/mocks/amm-quote-swap-sell.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "poolAddress": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "estimatedAmountIn": 0.001, - "estimatedAmountOut": 2.595146, - "minAmountOut": 2.54426, - "maxAmountIn": 0.001, - "baseTokenBalanceChange": -0.001, - "quoteTokenBalanceChange": 2.595146, - "price": 2595.146, - "gasPrice": 0.001860001, - "gasLimit": 300000, - "gasCost": 5.580003e-7 -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/amm-quote-swap.json b/test/connectors/uniswap/mocks/amm-quote-swap.json deleted file mode 100644 index fdd1861fe9..0000000000 --- a/test/connectors/uniswap/mocks/amm-quote-swap.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "poolAddress": "0x88a43bbdf9d098eec7bceda4e2494615dfd9bb9c", - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 2200.50, - "minAmountOut": 2178.50, - "maxAmountIn": 1.01, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 2200.50, - "price": 2200.50, - "computeUnits": 250000 -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/clmm-pool-info.json b/test/connectors/uniswap/mocks/clmm-pool-info.json deleted file mode 100644 index 0026fdf245..0000000000 --- a/test/connectors/uniswap/mocks/clmm-pool-info.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "address": "0xd0b53d9277642d899df5c87a3966a349a798f224", - "baseTokenAddress": "0x4200000000000000000000000000000000000006", - "quoteTokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "feePct": 0.05, - "price": 2202.75, - "baseTokenAmount": 150.25, - "quoteTokenAmount": 330963.1, - "tickSpacing": 10, - "tick": -202315 -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/clmm-quote-swap.json b/test/connectors/uniswap/mocks/clmm-quote-swap.json deleted file mode 100644 index 0686d11922..0000000000 --- a/test/connectors/uniswap/mocks/clmm-quote-swap.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "poolAddress": "0xd0b53d9277642d899df5c87a3966a349a798f224", - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 2202.75, - "minAmountOut": 2180.72, - "maxAmountIn": 1.01, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 2202.75, - "price": 2202.75, - "routePath": "WETH → USDC", - "computeUnits": 180000 -} \ No newline at end of file diff --git a/test/connectors/uniswap/mocks/execute-swap.json b/test/connectors/uniswap/mocks/execute-swap.json deleted file mode 100644 index ea73ca32d5..0000000000 --- a/test/connectors/uniswap/mocks/execute-swap.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "signature": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "data": { - "tokenIn": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "tokenOut": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "amountIn": 1.0, - "amountOut": 1800.0, - "fee": 0.003, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 1800.0 - } -} diff --git a/test/connectors/uniswap/mocks/quote-swap.json b/test/connectors/uniswap/mocks/quote-swap.json deleted file mode 100644 index b4a3f1ed75..0000000000 --- a/test/connectors/uniswap/mocks/quote-swap.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "estimatedAmountIn": 1.0, - "estimatedAmountOut": 1800.0, - "minAmountOut": 1782.0, - "maxAmountIn": 1.0, - "price": 1800.0, - "baseTokenBalanceChange": -1.0, - "quoteTokenBalanceChange": 1800.0, - "computeUnits": 250000, - "gasPrice": 50000000000, - "gasLimit": 300000, - "gasCost": 0.015 -} \ No newline at end of file diff --git a/test/connectors/uniswap/router-routes/executeQuote-permit2-expiration.test.ts b/test/connectors/uniswap/router-routes/executeQuote-permit2-expiration.test.ts index de216f8660..8bdeaa4c44 100644 --- a/test/connectors/uniswap/router-routes/executeQuote-permit2-expiration.test.ts +++ b/test/connectors/uniswap/router-routes/executeQuote-permit2-expiration.test.ts @@ -31,7 +31,7 @@ const mockWETH = { const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { executeQuoteRoute } = await import('../../../../src/connectors/uniswap/router-routes/executeQuote'); + const { executeQuoteRoute } = await import('../../../../src/trading/trading-router-routes/executeQuote'); await server.register(executeQuoteRoute); return server; }; @@ -114,7 +114,8 @@ describe('POST /execute-quote — Permit2 expiration handling', () => { method: 'POST', url: '/execute-quote', payload: { - network: 'robinhoodchain', + chainNetwork: 'ethereum-robinhoodchain', + connector: 'uniswap', walletAddress: '0xDA50C69342216b538Daf06FfECDa7363E0B96684', quoteId: 'test-quote-id', }, diff --git a/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts b/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts index fe554db677..cd88222a6d 100644 --- a/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts +++ b/test/connectors/uniswap/router-routes/universal-router-quoteSwap.test.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; import { Uniswap } from '../../../../src/connectors/uniswap/uniswap'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; +import { parseWire } from '../../../utils/wire'; jest.mock('../../../../src/chains/ethereum/ethereum'); jest.mock('../../../../src/connectors/uniswap/uniswap'); @@ -15,7 +16,7 @@ const mockGetAlphaRouterQuote = jest.fn(); const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { quoteSwapRoute } = await import('../../../../src/connectors/uniswap/router-routes/quoteSwap'); + const { quoteSwapRoute } = await import('../../../../src/trading/trading-router-routes/quoteSwap'); await server.register(quoteSwapRoute); return server; }; @@ -151,7 +152,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WETH', quoteToken: 'USDC', @@ -162,12 +164,12 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('quoteId', 'test-quote-id'); expect(body).toHaveProperty('tokenIn', mockWETH.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); - expect(body).toHaveProperty('amountIn', 1); + expect(Number(body.amountIn)).toBe(1); expect(body).toHaveProperty('amountOut'); expect(body.amountOut).toBeGreaterThan(0); expect(body).toHaveProperty('price'); @@ -194,7 +196,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WETH', quoteToken: 'USDC', @@ -205,58 +208,62 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('tokenIn', mockUSDC.address); expect(body).toHaveProperty('tokenOut', mockWETH.address); - expect(body).toHaveProperty('amountOut', 1); + expect(Number(body.amountOut)).toBe(1); expect(body).toHaveProperty('amountIn'); expect(body.amountIn).toBeGreaterThan(0); }); - it('should handle V3 protocol', async () => { + it('quotes through the universal router', async () => { const response = await server.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WETH', quoteToken: 'USDC', amount: '1', side: 'SELL', slippagePct: '1', - protocols: ['v3'], }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - // Protocols aren't returned in the response - they're only used for filtering + // `protocols` used to be sent here and was silently dropped: the unified router + // route declares no such parameter and never read one, so the "filtering" this case + // was written for never happened. The route answers with the path it chose. expect(body).toHaveProperty('routePath'); }); - it('should handle multiple protocols', async () => { + it('quotes the same pair a second time, without a protocol filter', async () => { const response = await server.inject({ method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WETH', quoteToken: 'USDC', amount: '1', side: 'SELL', slippagePct: '1', - protocols: ['v2', 'v3'], }, }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - // Protocols aren't returned in the response - they're only used for filtering + // `protocols` used to be sent here and was silently dropped: the unified router + // route declares no such parameter and never read one, so the "filtering" this case + // was written for never happened. The route answers with the path it chose. expect(body).toHaveProperty('routePath'); }); @@ -274,7 +281,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'INVALID', quoteToken: 'USDC', @@ -285,7 +293,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(404); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(body).toHaveProperty('message'); expect(body.message).toContain('Token not found'); }); @@ -296,7 +304,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'ETH', quoteToken: 'USDC', @@ -307,12 +316,12 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // Should use WETH address even though ETH was requested expect(body).toHaveProperty('tokenIn', mockWETH.address); expect(body).toHaveProperty('tokenOut', mockUSDC.address); - expect(body).toHaveProperty('amountIn', 1); + expect(Number(body.amountIn)).toBe(1); expect(body).toHaveProperty('amountOut'); expect(body.amountOut).toBeGreaterThan(0); }); @@ -338,7 +347,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'USDC', quoteToken: 'ETH', @@ -349,7 +359,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // Should use WETH address even though ETH was requested as quote // SELL side: input=base (USDC), output=quote (ETH->WETH) @@ -362,7 +372,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'eth', quoteToken: 'USDC', @@ -373,7 +384,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // Should convert lowercase 'eth' to WETH expect(body).toHaveProperty('tokenIn', mockWETH.address); @@ -384,7 +395,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'Eth', quoteToken: 'USDC', @@ -395,7 +407,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // Should convert mixed case 'Eth' to WETH expect(body).toHaveProperty('tokenIn', mockWETH.address); @@ -408,7 +420,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'USDC', quoteToken: 'USDC', @@ -419,9 +432,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - expect(body).toHaveProperty('price', 1); + expect(Number(body.price)).toBe(1); expect(body).toHaveProperty('amountIn', 100); expect(body).toHaveProperty('amountOut', 100); expect(body).toHaveProperty('priceImpactPct', 0); @@ -432,7 +445,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'ETH', quoteToken: 'WETH', @@ -443,9 +457,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - expect(body).toHaveProperty('price', 1); + expect(Number(body.price)).toBe(1); expect(body).toHaveProperty('amountIn', 1); expect(body).toHaveProperty('amountOut', 1); expect(body).toHaveProperty('priceImpactPct', 0); @@ -456,7 +470,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WETH', quoteToken: 'ETH', @@ -467,9 +482,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - expect(body).toHaveProperty('price', 1); + expect(Number(body.price)).toBe(1); expect(body).toHaveProperty('amountIn', 1); expect(body).toHaveProperty('amountOut', 1); expect(body).toHaveProperty('priceImpactPct', 0); @@ -480,7 +495,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'ETH', quoteToken: 'ETH', @@ -491,9 +507,9 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); - expect(body).toHaveProperty('price', 1); + expect(Number(body.price)).toBe(1); expect(body).toHaveProperty('amountIn', 1); expect(body).toHaveProperty('amountOut', 1); }); @@ -525,7 +541,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WETH', quoteToken: 'USDC', @@ -536,12 +553,12 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); expect(mockGetAlphaRouterQuote).not.toHaveBeenCalled(); expect(mockGetUniversalRouterQuote).toHaveBeenCalled(); expect(body).toHaveProperty('routePath', '100% via WETH -> USDC'); - expect(body).toHaveProperty('amountIn', 1); + expect(Number(body.amountIn)).toBe(1); expect(body).toHaveProperty('amountOut', 3000); expect(body).toHaveProperty('priceImpactPct', 0.5); }); @@ -551,7 +568,8 @@ describe('GET /quote-swap', () => { method: 'GET', url: '/quote-swap', query: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', + connector: 'uniswap', walletAddress: '0x0000000000000000000000000000000000000001', baseToken: 'WETH', quoteToken: 'USDC', @@ -562,7 +580,7 @@ describe('GET /quote-swap', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = parseWire(response.body); // The calldata's embedded min-out must be built from the same slippage // the response advertises, so the requested value has to reach the quote diff --git a/test/connectors/uniswap/swap.test.js b/test/connectors/uniswap/swap.test.js deleted file mode 100644 index d233a01f3f..0000000000 --- a/test/connectors/uniswap/swap.test.js +++ /dev/null @@ -1,384 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { test, describe, expect, beforeEach } = require('@jest/globals'); -const axios = require('axios'); - -// Constants for this test file -const CONNECTOR = 'uniswap'; -const CHAIN = 'ethereum'; -const NETWORK = 'base'; // Testing with Base network, but all Ethereum networks are supported -const BASE_TOKEN = 'WETH'; -const QUOTE_TOKEN = 'USDC'; -const TEST_WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; - -// Mock API calls (axios.get and axios.post) -jest.mock('axios'); - -// Mock implementation for axios -axios.get = jest.fn(); -axios.post = jest.fn(); - -// Helper to load mock responses -function loadMockResponse(filename) { - try { - // First try to find connector-specific mock - const filePath = path.join(__dirname, 'mocks', `${filename}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (error) { - // If not found, use generic mock template - const templatePath = path.join(__dirname, '..', '..', 'templates', 'mock-examples', `connector-${filename}.json`); - return JSON.parse(fs.readFileSync(templatePath, 'utf8')); - } -} - -// Function to validate swap quote response structure -function validateSwapQuote(response) { - return ( - response && - typeof response.estimatedAmountIn === 'number' && - typeof response.estimatedAmountOut === 'number' && - typeof response.minAmountOut === 'number' && - typeof response.maxAmountIn === 'number' && - typeof response.baseTokenBalanceChange === 'number' && - typeof response.quoteTokenBalanceChange === 'number' && - typeof response.price === 'number' - ); -} - -// Tests -describe('Uniswap V3 Swap Router Tests (Base Network)', () => { - beforeEach(() => { - // Reset axios mocks before each test - axios.get.mockClear(); - axios.post.mockClear(); - }); - - describe('Quote Swap Endpoint', () => { - test('returns and validates swap quote for SELL', async () => { - // Create a mock response based on generic template or existing mock - let mockResponse; - try { - mockResponse = loadMockResponse('quote-swap'); - } catch (error) { - // Create minimal mock if not found - mockResponse = { - estimatedAmountIn: 1.0, - estimatedAmountOut: 1800.0, - minAmountOut: 1782.0, - maxAmountIn: 1.0, - price: 1800.0, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - }; - } - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values for a SELL - expect(response.data.baseTokenBalanceChange).toBeLessThan(0); // SELL means negative base token change - expect(response.data.quoteTokenBalanceChange).toBeGreaterThan(0); // SELL means positive quote token change - - // Verify axios was called with correct parameters - expect(axios.get).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, - expect.objectContaining({ - params: expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }), - }), - ); - }); - - test('returns and validates swap quote for BUY', async () => { - // Create a mock response based on generic template or existing mock - let mockBuyResponse; - try { - const mockSellResponse = loadMockResponse('quote-swap'); - mockBuyResponse = { - ...mockSellResponse, - // Flip the values for BUY direction - estimatedAmountIn: mockSellResponse.estimatedAmountOut, // Quote amount needed - estimatedAmountOut: 1.0, // Base amount to receive - minAmountOut: 1.0, - maxAmountIn: mockSellResponse.estimatedAmountOut * 1.01, // Add 1% slippage - baseTokenBalanceChange: 1.0, // Positive for BUY - quoteTokenBalanceChange: -mockSellResponse.estimatedAmountOut, // Negative for BUY - // For BUY: price = quote needed / base received - price: mockSellResponse.estimatedAmountOut / 1.0, - }; - } catch (error) { - // Create minimal mock if not found - mockBuyResponse = { - estimatedAmountIn: 1800.0, - estimatedAmountOut: 1.0, - minAmountOut: 1.0, - maxAmountIn: 1818.0, - price: 1800.0, // For BUY: price = quote needed / base received = 1800.0 / 1.0 - baseTokenBalanceChange: 1.0, - quoteTokenBalanceChange: -1800.0, - gasPrice: 5.0, - gasLimit: 300000, - gasCost: 0.0015, - }; - } - - // Setup mock axios - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockBuyResponse, - }); - - // Make the request - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, { - params: { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, - }, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - - // Check expected mock values for a BUY - expect(response.data.baseTokenBalanceChange).toBeGreaterThan(0); // BUY means positive base token change - expect(response.data.quoteTokenBalanceChange).toBeLessThan(0); // BUY means negative quote token change - }); - - test('handles different networks correctly', async () => { - const networks = ['mainnet', 'arbitrum', 'optimism', 'base', 'polygon']; - - for (const network of networks) { - const mockResponse = { - estimatedAmountIn: 1.0, - estimatedAmountOut: 1800.0, - minAmountOut: 1782.0, - maxAmountIn: 1.0, - price: 1800.0, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - gasPrice: 5.0, - gasLimit: 300000, - gasCost: 0.0015, - }; - - axios.get.mockResolvedValueOnce({ - status: 200, - data: mockResponse, - }); - - const response = await axios.get(`http://localhost:15888/connectors/${CONNECTOR}/routes/quote-swap`, { - params: { - network, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - }, - }); - - expect(response.status).toBe(200); - expect(validateSwapQuote(response.data)).toBe(true); - } - }); - }); - - describe('Execute Swap Endpoint', () => { - test('returns successful swap execution with Uniswap V3 Swap Router', async () => { - // Create a quote-swap response to use as input for execute-swap - let quoteResponse; - try { - quoteResponse = loadMockResponse('quote-swap'); - } catch (error) { - // Create minimal mock if not found - quoteResponse = { - estimatedAmountIn: 1.0, - estimatedAmountOut: 1800.0, - minAmountOut: 1782.0, - maxAmountIn: 1.0, - price: 1800.0, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - }; - } - - // Mock a successful execution response - const executeResponse = { - signature: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - amountIn: quoteResponse.estimatedAmountIn, - amountOut: quoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: quoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: quoteResponse.quoteTokenBalanceChange, - }; - - // Setup mock axios for the execute-swap request - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - // Make the request - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - // Validate the response - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(quoteResponse.estimatedAmountIn); - expect(response.data.amountOut).toBe(quoteResponse.estimatedAmountOut); - - // Verify axios was called with correct parameters - expect(axios.post).toHaveBeenCalledWith( - `http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, - expect.objectContaining({ - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }), - ); - }); - - test('executes BUY swap successfully', async () => { - // Create a BUY quote response - const buyQuoteResponse = { - estimatedAmountIn: 2500.0, // USDC needed - estimatedAmountOut: 1.0, // WETH to receive - minAmountOut: 1.0, - maxAmountIn: 2525.0, // with slippage - price: 2500.0, - baseTokenBalanceChange: 1.0, - quoteTokenBalanceChange: -2500.0, - }; - - // Mock a successful BUY execution response - const executeBuyResponse = { - signature: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - amountIn: buyQuoteResponse.estimatedAmountIn, - amountOut: buyQuoteResponse.estimatedAmountOut, - fee: 0.003, - baseTokenBalanceChange: buyQuoteResponse.baseTokenBalanceChange, - quoteTokenBalanceChange: buyQuoteResponse.quoteTokenBalanceChange, - }; - - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeBuyResponse, - }); - - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'BUY', - amount: 1.0, // Want to buy 1 WETH - walletAddress: TEST_WALLET, - }); - - expect(response.status).toBe(200); - expect(response.data.signature).toBeDefined(); - expect(response.data.amountIn).toBe(2500.0); // USDC spent - expect(response.data.amountOut).toBe(1.0); // WETH received - expect(response.data.baseTokenBalanceChange).toBe(1.0); // +1 WETH - expect(response.data.quoteTokenBalanceChange).toBe(-2500.0); // -2500 USDC - }); - - test('validates slippage parameters', async () => { - const executeResponse = { - signature: '0x123...', - amountIn: 1.0, - amountOut: 1790.0, - fee: 0.003, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1790.0, - }; - - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network: NETWORK, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - slippagePct: 1.0, // 1% slippage - }); - - expect(response.status).toBe(200); - // With 1% slippage, the output should be at least 99% of expected - expect(response.data.amountOut).toBeGreaterThanOrEqual(1782.0); - }); - - test('handles multiple networks for execution', async () => { - const networks = ['mainnet', 'arbitrum', 'optimism', 'base']; - - for (const network of networks) { - const executeResponse = { - signature: `0x${network}1234567890abcdef`, - amountIn: 1.0, - amountOut: 1800.0, - fee: 0.003, - baseTokenBalanceChange: -1.0, - quoteTokenBalanceChange: 1800.0, - }; - - axios.post.mockResolvedValueOnce({ - status: 200, - data: executeResponse, - }); - - const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/routes/execute-swap`, { - network, - baseToken: BASE_TOKEN, - quoteToken: QUOTE_TOKEN, - side: 'SELL', - amount: 1.0, - walletAddress: TEST_WALLET, - }); - - expect(response.status).toBe(200); - expect(response.data.signature).toContain(network); - } - }); - }); -}); diff --git a/test/connectors/uniswap/uniswap.routes.test.ts b/test/connectors/uniswap/uniswap.routes.test.ts deleted file mode 100644 index deae8ba347..0000000000 --- a/test/connectors/uniswap/uniswap.routes.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import '../../mocks/app-mocks'; - -import { FastifyInstance } from 'fastify'; - -import { gatewayApp } from '../../../src/app'; - -describe('Uniswap Routes Structure', () => { - let fastify: FastifyInstance; - - beforeAll(async () => { - fastify = gatewayApp; - await fastify.ready(); - }); - - afterAll(async () => { - await fastify.close(); - }); - - describe('Folder Structure', () => { - it('should have router-routes, amm-routes, and clmm-routes folders', () => { - const uniswapPath = path.join(__dirname, '../../../src/connectors/uniswap'); - const routerRoutesPath = path.join(uniswapPath, 'router-routes'); - const ammRoutesPath = path.join(uniswapPath, 'amm-routes'); - const clmmRoutesPath = path.join(uniswapPath, 'clmm-routes'); - const oldRoutesPath = path.join(uniswapPath, 'routes'); - - expect(fs.existsSync(routerRoutesPath)).toBe(true); - expect(fs.existsSync(ammRoutesPath)).toBe(true); - expect(fs.existsSync(clmmRoutesPath)).toBe(true); - expect(fs.existsSync(oldRoutesPath)).toBe(false); - }); - - it('should have correct files in router-routes folder', () => { - const routerRoutesPath = path.join(__dirname, '../../../src/connectors/uniswap/router-routes'); - const files = fs.readdirSync(routerRoutesPath); - - expect(files).toContain('executeSwap.ts'); - expect(files).toContain('quoteSwap.ts'); - expect(files).toContain('executeQuote.ts'); - }); - }); - - describe('Route Registration', () => { - it('should register all Uniswap route types', async () => { - const routes = fastify.printRoutes(); - - // Check that Uniswap router routes are registered - expect(routes).toContain('uniswap/'); - expect(routes).toContain('router/'); - - // Check that Uniswap AMM routes are registered - expect(routes).toContain('amm/'); - - // Check that Uniswap CLMM routes are registered - expect(routes).toContain('clmm/'); - }); - }); -}); diff --git a/test/mocks/shared-mocks.ts b/test/mocks/shared-mocks.ts index 5132dea9b7..7a29277cdf 100644 --- a/test/mocks/shared-mocks.ts +++ b/test/mocks/shared-mocks.ts @@ -43,6 +43,19 @@ export const mockConfigStorage: Record = { 'uniswap.ttl': 300, }; +const mockNamespaces: Record = { + server: {}, + 'ethereum-mainnet': {}, + 'ethereum-goerli': {}, + 'solana-mainnet-beta': {}, + 'solana-devnet': {}, + uniswap: {}, + jupiter: {}, + meteora: {}, + raydium: {}, + orca: {}, +}; + export const mockConfigManagerV2 = { getInstance: jest.fn().mockReturnValue({ get: jest.fn().mockImplementation((key: string) => mockConfigStorage[key]), @@ -50,18 +63,15 @@ export const mockConfigManagerV2 = { mockConfigStorage[key] = value; }), getNamespace: jest.fn(), - namespaces: { - server: {}, - 'ethereum-mainnet': {}, - 'ethereum-goerli': {}, - 'solana-mainnet-beta': {}, - 'solana-devnet': {}, - uniswap: {}, - jupiter: {}, - meteora: {}, - raydium: {}, - orca: {}, - }, + namespaces: mockNamespaces, + // The trading routes read this at import time to build the chainNetwork enum, so a + // mock without it fails the suite on import rather than in a test. Derived from the + // namespaces above the way the real one is, so adding a namespace here is enough. + getSupportedChainNetworks: jest.fn(() => + Object.keys(mockNamespaces) + .filter((namespace) => ['ethereum', 'solana'].includes(namespace.split('-')[0])) + .sort(), + ), allConfigurations: mockConfigStorage, }), }; diff --git a/test/pools/pools.routes.test.ts b/test/pools/pools.routes.test.ts index 6ef21a1008..8e799d193f 100644 --- a/test/pools/pools.routes.test.ts +++ b/test/pools/pools.routes.test.ts @@ -1,5 +1,3 @@ -import Fastify, { FastifyInstance } from 'fastify'; - // Mock dependencies jest.mock('../../src/services/logger', () => ({ logger: { @@ -47,11 +45,15 @@ jest.mock('@fastify/sensible', () => { }); // Import after mocking +import { FastifyInstance } from 'fastify'; + import { poolRoutes } from '../../src/pools/pools.routes'; import { Pool } from '../../src/pools/types'; import { CoinGeckoService } from '../../src/services/coingecko-service'; import { PoolService } from '../../src/services/pool-service'; import { TokenService } from '../../src/services/token-service'; +import { fastifyWithTypeProvider } from '../utils/testUtils'; +import { parseWire } from '../utils/wire'; describe('Pool Routes Tests', () => { let fastify: FastifyInstance; @@ -61,7 +63,7 @@ describe('Pool Routes Tests', () => { beforeEach(async () => { // Create a new Fastify instance for each test - fastify = Fastify(); + fastify = fastifyWithTypeProvider(); // Setup PoolService mock mockPoolService = { @@ -183,11 +185,11 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/?chain=solana&network=mainnet-beta', + url: '/?chainNetwork=solana-mainnet-beta', }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.payload)).toEqual(mockPools); + expect(parseWire(response.payload)).toEqual(mockPools); expect(mockPoolService.listPools).toHaveBeenCalledWith('solana', 'mainnet-beta', undefined, undefined, undefined); }); @@ -210,11 +212,11 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/?chain=solana&network=mainnet-beta&connector=raydium&type=clmm', + url: '/?chainNetwork=solana-mainnet-beta&connector=raydium&type=clmm', }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.payload)).toEqual(mockPools); + expect(parseWire(response.payload)).toEqual(mockPools); expect(mockPoolService.listPools).toHaveBeenCalledWith('solana', 'mainnet-beta', 'raydium', 'clmm', undefined); }); @@ -237,11 +239,11 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/?chain=solana&network=mainnet-beta&search=SOL', + url: '/?chainNetwork=solana-mainnet-beta&search=SOL', }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.payload)).toEqual(mockPools); + expect(parseWire(response.payload)).toEqual(mockPools); expect(mockPoolService.listPools).toHaveBeenCalledWith('solana', 'mainnet-beta', undefined, undefined, 'SOL'); }); @@ -250,11 +252,11 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/?chain=invalid&network=mainnet', + url: '/?chainNetwork=invalid-mainnet', }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.payload)).toHaveProperty('message'); + expect(parseWire(response.payload)).toHaveProperty('message'); }); }); @@ -276,11 +278,11 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/SOL-USDC?chain=solana&network=mainnet-beta&type=amm&connector=raydium', + url: '/SOL-USDC?chainNetwork=solana-mainnet-beta&type=amm&connector=raydium', }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.payload)).toEqual(mockPool); + expect(parseWire(response.payload)).toEqual(mockPool); expect(mockPoolService.getPool).toHaveBeenCalledWith('solana', 'mainnet-beta', 'amm', 'SOL', 'USDC', 'raydium'); }); @@ -289,22 +291,22 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/UNKNOWN-TOKEN?chain=solana&network=mainnet-beta&type=amm', + url: '/UNKNOWN-TOKEN?chainNetwork=solana-mainnet-beta&type=amm', }); expect(response.statusCode).toBe(404); - expect(JSON.parse(response.payload)).toHaveProperty('message'); + expect(parseWire(response.payload)).toHaveProperty('message'); }); it('should return 400 for invalid trading pair format', async () => { const response = await fastify.inject({ method: 'GET', - url: '/INVALIDFORMAT?chain=solana&network=mainnet-beta&type=amm', + url: '/INVALIDFORMAT?chainNetwork=solana-mainnet-beta&type=amm', }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.payload)).toHaveProperty('message'); - expect(JSON.parse(response.payload).message).toContain('Invalid trading pair format'); + expect(parseWire(response.payload)).toHaveProperty('message'); + expect(parseWire(response.payload).message).toContain('Invalid trading pair format'); }); }); @@ -318,10 +320,9 @@ describe('Pool Routes Tests', () => { method: 'POST', url: '/', payload: { - chain: 'solana', + chainNetwork: 'solana-mainnet-beta', connector: 'raydium', type: 'amm', - network: 'mainnet-beta', baseSymbol: 'WIF', quoteSymbol: 'SOL', address: 'EP2ib6dYdEeqD8MfE2ezHCxX3kP3K2eLKkirfPm5eyMx', @@ -332,8 +333,8 @@ describe('Pool Routes Tests', () => { }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.payload)).toHaveProperty('message'); - expect(JSON.parse(response.payload).message).toContain('Pool WIF-SOL'); + expect(parseWire(response.payload)).toHaveProperty('message'); + expect(parseWire(response.payload).message).toContain('Pool WIF-SOL'); // Verify addPool was called with chain, network, and pool data expect(mockPoolService.addPool).toHaveBeenCalledWith( @@ -372,10 +373,9 @@ describe('Pool Routes Tests', () => { method: 'POST', url: '/', payload: { - chain: 'solana', + chainNetwork: 'solana-mainnet-beta', connector: 'raydium', type: 'amm', - network: 'mainnet-beta', baseSymbol: 'SOL', quoteSymbol: 'USDC', address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', @@ -386,7 +386,7 @@ describe('Pool Routes Tests', () => { }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.payload)).toHaveProperty('message'); + expect(parseWire(response.payload)).toHaveProperty('message'); expect(mockPoolService.updatePool).toHaveBeenCalled(); }); @@ -412,12 +412,12 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2?chain=solana&network=mainnet-beta', + url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2?chainNetwork=solana-mainnet-beta', }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.payload)).toHaveProperty('message'); - expect(JSON.parse(response.payload).message).toContain('Pool with address'); + expect(parseWire(response.payload)).toHaveProperty('message'); + expect(parseWire(response.payload).message).toContain('Pool with address'); expect(mockPoolService.removePool).toHaveBeenCalledWith( 'solana', @@ -431,18 +431,19 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/NonExistent?chain=solana&network=mainnet-beta', + url: '/NonExistent?chainNetwork=solana-mainnet-beta', }); expect(response.statusCode).toBe(404); - expect(JSON.parse(response.payload)).toHaveProperty('message'); + expect(parseWire(response.payload)).toHaveProperty('message'); }); it('should return 400 for missing required parameters', async () => { const response = await fastify.inject({ method: 'DELETE', - url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2?chain=solana', - // Missing network + url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', + // No chainNetwork. A delete must say which list it is deleting from — defaulting + // one would pick a network and remove a pool from it. }); expect(response.statusCode).toBe(400); @@ -518,7 +519,7 @@ describe('Pool Routes Tests', () => { }); expect(response.statusCode).toBe(200); - const result = JSON.parse(response.payload); + const result = parseWire(response.payload); // Verify response is in PoolInfo format expect(result).toHaveLength(2); @@ -560,7 +561,7 @@ describe('Pool Routes Tests', () => { }); expect(response.statusCode).toBe(200); - const result = JSON.parse(response.payload); + const result = parseWire(response.payload); expect(result).toEqual([]); }); @@ -571,7 +572,7 @@ describe('Pool Routes Tests', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.payload).message).toContain('Unsupported chainNetwork format'); + expect(parseWire(response.payload).message).toContain('Unsupported chainNetwork format'); }); it('should return 500 on service error', async () => { @@ -583,7 +584,7 @@ describe('Pool Routes Tests', () => { }); expect(response.statusCode).toBe(500); - expect(JSON.parse(response.payload).message).toContain('Failed to fetch pools from GeckoTerminal'); + expect(parseWire(response.payload).message).toContain('Failed to fetch pools from GeckoTerminal'); }); }); }); diff --git a/test/services/chain-network.test.ts b/test/services/chain-network.test.ts new file mode 100644 index 0000000000..ba57561662 --- /dev/null +++ b/test/services/chain-network.test.ts @@ -0,0 +1,54 @@ +import { parseChainNetwork } from '../../src/services/chain-network'; + +/** + * One reading of `chain-network`, not three. + * + * The implementations had diverged in a way callers could feel: trading rejected a value + * with no hyphen, ConfigManagerV2 answered `{ chain: 'solana', network: '' }` for + * `"solana"`, and findPools hand-rolled the same lenient split inline and stamped the + * empty network onto every pool it returned. Which behaviour a caller got depended on + * which route they reached. + */ +describe('parseChainNetwork', () => { + it.each([ + ['solana-mainnet-beta', { chain: 'solana', network: 'mainnet-beta' }], + ['ethereum-mainnet', { chain: 'ethereum', network: 'mainnet' }], + // The network keeps its hyphens; only the first segment is the chain. + ['ethereum-robinhoodchain-testnet', { chain: 'ethereum', network: 'robinhoodchain-testnet' }], + ])('reads %s', (input, expected) => { + expect(parseChainNetwork(input)).toEqual(expected); + }); + + // Each of these used to be accepted somewhere, and each produced an empty half that + // then travelled on as though it were a real network. + it.each([['solana'], ['solana-'], ['-mainnet-beta'], [''], ['-']])('rejects %p', (input) => { + expect(() => parseChainNetwork(input)).toThrow(/Invalid chainNetwork/); + }); + + // handlePoolError matches on this text to answer 400. If the wording drifts, a caller's + // malformed selector starts being reported as a Gateway failure instead. + it('says "Invalid chainNetwork", which is what the pool routes match on to answer 400', () => { + expect(() => parseChainNetwork('solana')).toThrow(/Invalid chainNetwork 'solana'/); + }); +}); + +describe('the callers share it', () => { + it('gives the trading routes a 400 rather than a raw error', () => { + const { parseChainNetwork: tradingParse } = require('../../src/trading/common'); + + expect(() => tradingParse('solana')).toThrow(/Invalid chainNetwork/); + try { + tradingParse('solana'); + } catch (e: any) { + expect(e.statusCode).toBe(400); + } + }); + + it('leaves no hand-rolled split behind in the pool routes', () => { + const fs = require('fs'); + const path = require('path'); + const source = fs.readFileSync(path.resolve(__dirname, '../../src/pools/routes/findPools.ts'), 'utf8'); + + expect(source).not.toMatch(/chainNetwork\.split\('-'\)/); + }); +}); diff --git a/test/services/data/config-manager-v2/test1/namespace/server-schema.json b/test/services/data/config-manager-v2/test1/namespace/server-schema.json index 7af7266679..52136dc408 100644 --- a/test/services/data/config-manager-v2/test1/namespace/server-schema.json +++ b/test/services/data/config-manager-v2/test1/namespace/server-schema.json @@ -12,9 +12,5 @@ "fastifyLogs": { "type": "boolean" } }, "additionalProperties": false, - "required": [ - "port", - "certificatePath", - "logPath" - ] + "required": ["port", "certificatePath", "logPath"] } diff --git a/test/services/data/config-manager-v2/test1/namespace/solana_network_config.json b/test/services/data/config-manager-v2/test1/namespace/solana_network_config.json index e1832174cc..a2c653d137 100644 --- a/test/services/data/config-manager-v2/test1/namespace/solana_network_config.json +++ b/test/services/data/config-manager-v2/test1/namespace/solana_network_config.json @@ -13,8 +13,5 @@ "type": "string" } }, - "required": [ - "nodeURL", - "nativeCurrencySymbol" - ] -} \ No newline at end of file + "required": ["nodeURL", "nativeCurrencySymbol"] +} diff --git a/test/services/gateway-security.test.ts b/test/services/gateway-security.test.ts index 96fdbc28de..3b10eddf5f 100644 --- a/test/services/gateway-security.test.ts +++ b/test/services/gateway-security.test.ts @@ -83,15 +83,33 @@ describe('gateway-security', () => { '/wallet', '/wallet/add', '/wallet/add-swig', - '/connectors/orca/clmm/execute-swap', - '/connectors/jupiter/router/execute-quote', '/restart', - '/trading/swap/execute', + // Router surface: both fund-moving verbs. + '/trading/router/execute-swap', + '/trading/router/execute-quote', + // Pool-scoped surfaces: swaps and every liquidity mutation. The AMM entries + // matter most — they were previously gated only through /connectors/*, which + // no longer exists, so a gap here would silently expose them. + '/trading/clmm/execute-swap', '/trading/clmm/open', '/trading/clmm/add', '/trading/clmm/remove', '/trading/clmm/collect-fees', '/trading/clmm/close', + '/trading/clmm/create-pool', + '/trading/amm/execute-swap', + '/trading/amm/open', + '/trading/amm/add', + '/trading/amm/remove', + '/trading/amm/close', + '/trading/amm/create-pool', + // Chain routes that sign. `approve` is the one that was missing: reachable + // unauthenticated, it makes the hot wallet approve an unlimited allowance to an + // address the caller chooses, and transferFrom does the rest. + '/chains/ethereum/approve', + '/chains/ethereum/wrap', + '/chains/solana/unwrap', + '/chains/ethereum/APPROVE', ])('sensitive: %s', (url) => { expect(isSensitivePath(url)).toBe(true); }); @@ -99,12 +117,22 @@ describe('gateway-security', () => { '/docs', '/config/namespaces', '/chains/solana/status', - '/connectors/orca/clmm/quote-swap', - '/trading/swap/quote', + '/chains/ethereum/allowances', + '/chains/ethereum/balances', + '/chains/ethereum/estimate-gas', + '/chains/solana/poll', + // Not a chain route at all; the pattern must not reach past the chain segment. + '/tokens/chains/ethereum/approve', + '/trading/router/quote-swap', + '/trading/clmm/quote-swap', + '/trading/amm/quote-swap', '/trading/clmm/pool-info', '/trading/clmm/position-info', '/trading/clmm/positions-owned', - '/trading/clmm/quote-position', + '/trading/clmm/quote-liquidity', + '/trading/clmm/fetch-pools', + '/trading/amm/pool-info', + '/trading/amm/quote-liquidity', ])('not sensitive: %s', (url) => { expect(isSensitivePath(url)).toBe(false); }); diff --git a/test/services/token-pool-autosave.test.ts b/test/services/token-pool-autosave.test.ts new file mode 100644 index 0000000000..4239744c47 --- /dev/null +++ b/test/services/token-pool-autosave.test.ts @@ -0,0 +1,196 @@ +/** + * Gateway learns tokens and pools from the chain as they are used. + * + * The risk these cover is not "did it save" but "did it save something wrong": the token + * list is keyed by symbol and pools pair by symbol, so a placeholder name or a symbol + * collision corrupts the lookups every later call depends on. + */ +const mockTokenService = { + getToken: jest.fn(), + addToken: jest.fn().mockResolvedValue(undefined), +}; +const mockPoolService = { + getPoolByAddress: jest.fn(), + addPool: jest.fn().mockResolvedValue(undefined), +}; +const mockSolana = { fetchTokenFromChain: jest.fn() }; +const mockLogger = { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() }; + +jest.mock('../../src/services/logger', () => ({ logger: mockLogger })); + +jest.mock('../../src/services/token-service', () => ({ + TokenService: { getInstance: () => mockTokenService }, +})); +jest.mock('../../src/services/pool-service', () => ({ + PoolService: { getInstance: () => mockPoolService }, +})); +jest.mock('../../src/chains/solana/solana', () => ({ + Solana: { getInstance: jest.fn().mockResolvedValue(mockSolana) }, +})); +jest.mock('../../src/chains/ethereum/ethereum', () => ({ + Ethereum: { getInstance: jest.fn() }, +})); + +import { ensurePoolSaved, ensureTokenSaved } from '../../src/services/token-pool-autosave'; + +const BONK = { + address: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', + symbol: 'BONK', + name: 'Bonk', + decimals: 5, + chainId: 101, +}; +const USDC = { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + chainId: 101, +}; + +const poolFacts = { + address: 'POOL', + baseTokenAddress: BONK.address, + quoteTokenAddress: USDC.address, + feePct: 0.25, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockTokenService.addToken.mockResolvedValue(undefined); + mockPoolService.addPool.mockResolvedValue(undefined); +}); + +describe('ensureTokenSaved', () => { + it('returns a token already in the list without reading the chain', async () => { + mockTokenService.getToken.mockResolvedValue(USDC); + + await expect(ensureTokenSaved('solana', 'mainnet-beta', USDC.address)).resolves.toEqual(USDC); + + expect(mockSolana.fetchTokenFromChain).not.toHaveBeenCalled(); + expect(mockTokenService.addToken).not.toHaveBeenCalled(); + }); + + it('reads an unknown address from the chain and adds it', async () => { + mockTokenService.getToken.mockResolvedValue(null); + mockSolana.fetchTokenFromChain.mockResolvedValue(BONK); + + await expect(ensureTokenSaved('solana', 'mainnet-beta', BONK.address)).resolves.toEqual(BONK); + + expect(mockTokenService.addToken).toHaveBeenCalledWith('solana', 'mainnet-beta', BONK); + }); + + // The chain gives decimals for any mint but a name for only some. Storing a token the + // chain would not name means storing one under a name nothing invented it to match. + it('stores nothing when the chain has no name for the token', async () => { + mockTokenService.getToken.mockResolvedValue(null); + mockSolana.fetchTokenFromChain.mockResolvedValue(null); + + await expect(ensureTokenSaved('solana', 'mainnet-beta', 'SomeUnnamedMint')).resolves.toBeNull(); + + expect(mockTokenService.addToken).not.toHaveBeenCalled(); + // Declining to store is a decision, not a failure. Asserting the reported reason is + // what separates it from tripping over the missing name and landing in the catch, + // which also ends with nothing stored. + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('leaving it unlisted')); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + // addToken treats a symbol collision as an update and rewrites the stored address, so + // this is the case that would silently repoint an existing token. Wrapped SOL reports + // its on-chain symbol as "SOL", which the list already holds for the native mint. + it('refuses to save a token whose symbol is held by a different address', async () => { + const wsolOnChain = { ...USDC, address: 'So11111111111111111111111111111111111111112', symbol: 'SOL' }; + mockTokenService.getToken.mockImplementation(async (_c: string, _n: string, key: string) => + key === 'SOL' ? { ...wsolOnChain, address: 'NativeSolAddress' } : null, + ); + mockSolana.fetchTokenFromChain.mockResolvedValue(wsolOnChain); + + await expect(ensureTokenSaved('solana', 'mainnet-beta', wsolOnChain.address)).resolves.toBeNull(); + + expect(mockTokenService.addToken).not.toHaveBeenCalled(); + }); + + it('reports a storage failure without raising it at the caller', async () => { + mockTokenService.getToken.mockResolvedValue(null); + mockSolana.fetchTokenFromChain.mockResolvedValue(BONK); + mockTokenService.addToken.mockRejectedValue(new Error('disk full')); + + await expect(ensureTokenSaved('solana', 'mainnet-beta', BONK.address)).resolves.toBeNull(); + }); +}); + +describe('ensurePoolSaved', () => { + const save = (fetchPoolInfo: any) => + ensurePoolSaved({ + chain: 'solana', + network: 'mainnet-beta', + connector: 'raydium', + type: 'amm', + poolAddress: 'POOL', + fetchPoolInfo, + }); + + // The short-circuit is the whole cost story: a known pool must not reach the connector. + it('does not fetch pool info for a pool it already knows', async () => { + mockPoolService.getPoolByAddress.mockResolvedValue({ address: 'POOL' }); + const fetchPoolInfo = jest.fn(); + + await save(fetchPoolInfo); + + expect(fetchPoolInfo).not.toHaveBeenCalled(); + expect(mockPoolService.addPool).not.toHaveBeenCalled(); + }); + + it('records an unknown pool under the symbols of its two tokens', async () => { + mockPoolService.getPoolByAddress.mockResolvedValue(null); + mockTokenService.getToken.mockResolvedValue(null); + mockSolana.fetchTokenFromChain.mockImplementation(async (address: string) => + address === BONK.address ? BONK : USDC, + ); + + await save(async () => poolFacts); + + expect(mockPoolService.addPool).toHaveBeenCalledWith('solana', 'mainnet-beta', { + connector: 'raydium', + type: 'amm', + network: 'mainnet-beta', + address: 'POOL', + baseSymbol: 'BONK', + quoteSymbol: 'USDC', + baseTokenAddress: BONK.address, + quoteTokenAddress: USDC.address, + feePct: 0.25, + }); + }); + + // A pool is filed under its pair, so half a pair is not a lesser record — it is an + // unusable one, and the token that did resolve is still worth keeping. + it('records no pool when one of its tokens cannot be named', async () => { + mockPoolService.getPoolByAddress.mockResolvedValue(null); + mockTokenService.getToken.mockResolvedValue(null); + mockSolana.fetchTokenFromChain.mockImplementation(async (address: string) => + address === BONK.address ? BONK : null, + ); + + await save(async () => poolFacts); + + expect(mockTokenService.addToken).toHaveBeenCalledWith('solana', 'mainnet-beta', BONK); + expect(mockPoolService.addPool).not.toHaveBeenCalled(); + // As above: the skip is reported as a decision, and nothing was thrown to get here. + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('has no symbol')); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('reports a connector failure without raising it at the caller', async () => { + mockPoolService.getPoolByAddress.mockResolvedValue(null); + + await expect( + save(async () => { + throw new Error('pool not found on chain'); + }), + ).resolves.toBeUndefined(); + + expect(mockPoolService.addPool).not.toHaveBeenCalled(); + }); +}); diff --git a/test/spec/addressing.test.ts b/test/spec/addressing.test.ts new file mode 100644 index 0000000000..3ddd068fb7 --- /dev/null +++ b/test/spec/addressing.test.ts @@ -0,0 +1,96 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * One way to say where a request applies. + * + * There used to be three, two of them inside a single router: `/pools/` took `chain` and + * `network` separately, `/pools/find` took `chainNetwork`, and `/chains/{chain}/*` took a + * path parameter plus a query. A caller had to learn which route wanted which, and the two + * halves could disagree — `chain=solana&network=mainnet` names nothing. + * + * The rule is now: a route addressed by a `chain` path parameter keeps it; everything else + * takes one `chainNetwork`. Wallets are the deliberate exception and are covered below. + */ +const spec = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../openapi.json'), 'utf8')); + +const fieldsOf = (op: any): Set => { + const params = (op.parameters ?? []).map((p: any) => p.name); + let body = op.requestBody?.content?.['application/json']?.schema ?? {}; + if (body.$ref) body = spec.components.schemas[body.$ref.replace('#/components/schemas/', '')] ?? {}; + return new Set([...params, ...Object.keys(body.properties ?? {})]); +}; + +const operations = (): Array<{ label: string; route: string; op: any }> => + Object.entries(spec.paths as Record).flatMap(([route, ops]) => + Object.entries(ops as Record).map(([method, op]) => ({ + label: `${method.toUpperCase()} ${route}`, + route, + op, + })), + ); + +/** A wallet is a keypair, and a keypair works on every network of its chain. */ +const WALLET_ROUTES = /^\/wallet\//; + +describe('every route addresses a chain-network one way', () => { + it.each(operations().map(({ label, route, op }) => [label, route, op]))('%s', (_label, route, op) => { + const fields = fieldsOf(op); + const addressed = ['chainNetwork', 'chain', 'network'].filter((f) => fields.has(f)); + if (addressed.length === 0) return; + + if ((route as string).startsWith('/chains/')) { + // The chain is in the path, so the body names only the network. + expect(fields.has('chainNetwork')).toBe(false); + return; + } + + if (WALLET_ROUTES.test(route as string)) { + expect(addressed).toEqual(['chain']); + return; + } + + expect(addressed).toEqual(['chainNetwork']); + }); + + // Named separately because it is the pairing that used to be wrong: the same router + // asked for the same thing two ways depending on which of its routes you reached. + it('asks /pools and /tokens the same way on every one of their routes', () => { + const inconsistent = operations() + .filter(({ route }) => route.startsWith('/pools/') || route.startsWith('/tokens/')) + .filter(({ op }) => { + const fields = fieldsOf(op); + return fields.has('chain') || fields.has('network') || !fields.has('chainNetwork'); + }) + .map(({ label }) => label); + + expect(inconsistent).toEqual([]); + }); + + // Fastify injects a schema default before the handler runs, so a defaulted + // chainNetwork on a delete would pick a list and remove from it. + it.each( + operations() + .filter(({ label }) => label.startsWith('DELETE') || label.startsWith('POST')) + .filter(({ route }) => route.startsWith('/pools/') || route.startsWith('/tokens/')) + .map(({ label, op }) => [label, op]), + )('%s makes the caller name the chain-network', (_label, op) => { + const schemas = [ + ...(op.parameters ?? []).map((p: any) => p.schema), + ...Object.entries( + (() => { + let body = op.requestBody?.content?.['application/json']?.schema ?? {}; + if (body.$ref) body = spec.components.schemas[body.$ref.replace('#/components/schemas/', '')] ?? {}; + return body.properties ?? {}; + })(), + ) + .filter(([name]) => name === 'chainNetwork') + .map(([, schema]) => schema), + ].filter(Boolean); + + const chainNetwork = schemas.find( + (s: any) => Array.isArray(s?.enum) && s.enum.some((v: string) => v.includes('-')), + ); + if (chainNetwork) expect(chainNetwork.default).toBeUndefined(); + }); +}); diff --git a/test/spec/operation-ids.test.ts b/test/spec/operation-ids.test.ts new file mode 100644 index 0000000000..e217f491dd --- /dev/null +++ b/test/spec/operation-ids.test.ts @@ -0,0 +1,93 @@ +import fs from 'fs'; +import path from 'path'; + +import { OPERATION_IDS } from '../../src/services/operation-ids'; + +/** + * Every operation is named, and named the same way twice running. + * + * `operationId` is the method name in a generated client. Gateway declared none, so every + * generator invented one from the method and path — meaning a caller's `client.foo()` was + * renamed by any path change, the same churn the component names were given `$id`s to + * avoid. The names are chosen in `operation-ids.ts` rather than derived, and that only + * holds while the table and the route table agree, in both directions. + */ +const spec = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../openapi.json'), 'utf8')); + +const operations = (): Array<{ method: string; route: string; op: any }> => + Object.entries(spec.paths as Record).flatMap(([route, ops]) => + Object.entries(ops as Record).map(([method, op]) => ({ method, route, op })), + ); + +describe('every operation carries a chosen name', () => { + it.each(operations().map(({ method, route, op }) => [`${method.toUpperCase()} ${route}`, op]))( + '%s has an operationId', + (_label, op) => { + expect(typeof op.operationId).toBe('string'); + expect(op.operationId.length).toBeGreaterThan(0); + }, + ); + + // A duplicate is worse than a missing one: two operations collapse into a single method + // on the generated client, and which one survives is the generator's choice. + it('gives no two operations the same name', () => { + const seen = new Map(); + const collisions: string[] = []; + for (const { method, route, op } of operations()) { + const previous = seen.get(op.operationId); + if (previous) collisions.push(`${op.operationId}: ${previous} and ${method.toUpperCase()} ${route}`); + seen.set(op.operationId, `${method.toUpperCase()} ${route}`); + } + + expect(collisions).toEqual([]); + }); + + // Both directions. A route with no entry is unnamed; an entry with no route is a name + // a caller may already depend on, left pointing at nothing. + it('has an entry for every route and a route for every entry', () => { + const routes = new Set(operations().map(({ method, route }) => `${method.toUpperCase()} ${route}`)); + const entries = new Set(Object.keys(OPERATION_IDS)); + + expect([...routes].filter((key) => !entries.has(key)).sort()).toEqual([]); + expect([...entries].filter((key) => !routes.has(key)).sort()).toEqual([]); + }); + + it('publishes the name the table chose, not one derived from the path', () => { + for (const { method, route, op } of operations()) { + expect(op.operationId).toBe(OPERATION_IDS[`${method.toUpperCase()} ${route}`]); + } + }); +}); + +describe('every operation describes how it fails', () => { + // Three of 56 declared any non-2xx response, so a generated client had no error model at + // all — while `code` is the field callers branch on to decide whether to retry. + it.each(operations().map(({ method, route, op }) => [`${method.toUpperCase()} ${route}`, op]))( + '%s declares 400 and 500', + (_label, op) => { + expect(Object.keys(op.responses)).toEqual(expect.arrayContaining(['400', '500'])); + }, + ); + + it('points them all at one published envelope', () => { + expect(spec.components.schemas.ErrorResponse).toBeDefined(); + expect(Object.keys(spec.components.schemas.ErrorResponse.properties)).toEqual( + expect.arrayContaining(['statusCode', 'error', 'message', 'code']), + ); + + for (const { op } of operations()) { + for (const status of ['400', '500']) { + expect(op.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/ErrorResponse'); + } + } + }); + + // The tag becomes a class name in most generators, so a singular stray puts two chain + // routes in a class of their own. + it('tags every operation with a declared tag', () => { + const declared = new Set((spec.tags ?? []).map((tag: any) => tag.name)); + const used = new Set(operations().flatMap(({ op }) => op.tags ?? [])); + + expect([...used].filter((tag) => !declared.has(tag)).sort()).toEqual([]); + }); +}); diff --git a/test/spec/request-components.test.ts b/test/spec/request-components.test.ts new file mode 100644 index 0000000000..e2b6db18b9 --- /dev/null +++ b/test/spec/request-components.test.ts @@ -0,0 +1,172 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * The request half of the generated-client contract (GW-9). + * + * Gateway's request bodies used to be anonymous objects declared in the route file, so + * nothing named them in the spec and a generated client had to hand-write every request + * model. The `$id`'d shapes in src/schemas were no substitute: they are the *base* types + * the unified routes compose from, and predate the refactor, so they carry a per-connector + * `network` and neither `connector` nor `chainNetwork`. A client generated from those was + * wrong the same way for every route. + * + * These read the committed spec rather than building the app, which means they check the + * document consumers actually generate from — and equally means they cannot notice that + * it is stale. A route change with no regeneration leaves every case here passing. + * Catching that needs regeneration and a comparison, which is the `Check openapi.json is + * regenerated` step in CI, not a test. + */ +const spec = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../openapi.json'), 'utf8')); +const components: Record = spec.components?.schemas ?? {}; + +const requestBodies = (): Array<{ method: string; route: string; op: any }> => + Object.entries(spec.paths as Record).flatMap(([route, ops]) => + Object.entries(ops as Record) + .filter(([, op]) => op?.requestBody) + .map(([method, op]) => ({ method, route, op })), + ); + +const refOf = (op: any): string | undefined => { + const schema = op.requestBody?.content?.['application/json']?.schema; + const ref: string | undefined = schema?.$ref; + return ref?.replace('#/components/schemas/', ''); +}; + +const propsOf = (name: string): string[] => Object.keys(components[name]?.properties ?? {}).sort(); + +/** A GET's query fields — path parameters are part of the route, not of the query shape. */ +const queryParams = (op: any): string[] => + (op.parameters ?? []) + .filter((p: any) => p.in === 'query') + .map((p: any) => p.name) + .sort(); + +const tradingGets = (): Array<{ route: string; params: string[] }> => + Object.entries(spec.paths as Record) + .filter(([route]) => route.startsWith('/trading/')) + .filter(([, ops]) => ops.get?.parameters) + .map(([route, ops]) => ({ route, params: queryParams(ops.get) })); + +/** + * The component describing a GET's query, found by shape rather than by reference. + * + * A GET cannot point at its component the way a body does: @fastify/swagger expands a + * querystring into `parameters`, so nothing in the operation carries a `$ref`. The + * component is published all the same — registering a schema and referencing it are + * independent — so it is identified here by having exactly the operation's fields. + */ +const componentsForGet = (params: string[]): string[] => + Object.keys(components).filter((name) => JSON.stringify(propsOf(name)) === JSON.stringify(params)); + +const componentForGet = (params: string[]): string | undefined => componentsForGet(params)[0]; + +/** + * The component name a `/trading` GET must publish, derived from its own path. + * + * Matching by shape alone cannot tell twins apart: `/trading/amm/quote-swap` and + * `/trading/clmm/quote-swap` have identical query fields, as do the two positions-owned + * reads. Removing one twin's `$id` therefore left every case here passing, because the + * other still matched — the component vanished from the spec and nothing said so. A name + * is unique, so this pins each read to the class a client will actually reach for. + */ +const expectedComponentName = (route: string): string => { + const [, , type, operation] = route.split('/'); + const pascal = (value: string) => + value + .split('-') + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(''); + return `${pascal(type)}${pascal(operation)}Request`; +}; + +describe('OpenAPI request bodies are generatable', () => { + it('names every /trading request body as a component', () => { + const unnamed = requestBodies() + .filter(({ route }) => route.startsWith('/trading/')) + .filter(({ op }) => !refOf(op)) + .map(({ method, route }) => `${method.toUpperCase()} ${route}`); + + expect(unnamed).toEqual([]); + }); + + it('resolves every $ref in the spec to a component that exists', () => { + const refs = new Set( + [...JSON.stringify(spec).matchAll(/#\/components\/schemas\/([A-Za-z0-9_]+)/g)].map((m) => m[1]), + ); + const dangling = [...refs].filter((name) => !(name in components)).sort(); + + expect(dangling).toEqual([]); + }); + + it.each( + requestBodies() + .filter(({ route }) => route.startsWith('/trading/')) + .map(({ method, route, op }) => [`${method.toUpperCase()} ${route}`, refOf(op)!]), + )('%s publishes the shape actually on the wire (%s)', (_label, component) => { + const schema = components[component]; + expect(schema).toBeDefined(); + + const props = Object.keys(schema.properties ?? {}); + // The two fields every unified trading call sends, and the two the stale base types + // were missing. `network` is the field they had instead — its presence here would + // mean a base type got published in place of the route's own body. + expect(props).toContain('connector'); + expect(props).toContain('chainNetwork'); + expect(props).not.toContain('network'); + }); + + // The reads are most of this API, and were the half GW-9 left behind: their fields + // reach the spec as `parameters`, so it looked as though no component could describe + // them. It can — publishing does not depend on being referenced — and until it did, + // the names a client reaches for (ClmmQuoteSwapRequest, FetchPoolsRequest) were held + // by pre-refactor bases carrying `network` and no `connector`. + it.each(tradingGets().map(({ route, params }) => [route, params]))( + 'GET %s publishes a component matching its query', + (route, params) => { + // By name first: the name is what a generated client imports, and it is the half a + // shape match cannot check, because two reads can share a shape. + const name = expectedComponentName(route as string); + expect(Object.keys(components)).toContain(name); + + // Then by shape, so the right name cannot be published over the wrong fields — + // which is the trap GW-10 found, with `ClmmQuoteSwapRequest` held by a pre-refactor + // base carrying `network` and no `connector`. + expect(propsOf(name)).toEqual(params as string[]); + + const props = propsOf(name); + expect(props).toContain('connector'); + expect(props).toContain('chainNetwork'); + expect(props).not.toContain('network'); + }, + ); + + it('publishes nothing that no route serves', () => { + // Every component is either referenced, or is a GET's query shape. A component that + // is neither is a stale base: it generates a class, under a name a caller trusts, + // for a shape Gateway never sends or accepts. + // + // Matching by shape is what makes this possible at all — a GET has no $ref to follow + // — and it is also the limit: a stale schema whose fields happen to equal some GET's + // query is excused. In practice that only reaches the single-field `{ network }` + // shapes the chain routes use, because every stale trading base carries `network` + // where the live ones carry `chainNetwork`. + // Across the whole document, not just the paths: a nested `data` shape is referenced + // by its parent component rather than by any operation. + const referenced = new Set( + [...JSON.stringify(spec).matchAll(/#\/components\/schemas\/([A-Za-z0-9_]+)/g)].map((m) => m[1]), + ); + // All matches, not the first: the AMM and CLMM reads of the same kind have identical + // query shapes, so one lookup would leave the other looking like an orphan. + const getShapes = new Set( + Object.values(spec.paths as Record) + .filter((ops: any) => ops.get?.parameters) + .flatMap((ops: any) => componentsForGet(queryParams(ops.get))), + ); + const orphans = Object.keys(components) + .filter((name) => !referenced.has(name) && !getShapes.has(name)) + .sort(); + + expect(orphans).toEqual([]); + }); +}); diff --git a/test/spec/response-components.test.ts b/test/spec/response-components.test.ts new file mode 100644 index 0000000000..980b24fa50 --- /dev/null +++ b/test/spec/response-components.test.ts @@ -0,0 +1,72 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * The response half of the generated-client contract. + * + * GW-9 and GW-10 named every request; the responses kept the names they had before the + * two surfaces existed side by side, so the unprefixed one was the CLMM one and a reader + * had to know that: `PoolInfo` against `AmmPoolInfo`, `AddLiquidityResponse` against + * `AmmAddLiquidityResponse`. `QuotePositionResponse` was worse — the route was renamed to + * quote-liquidity in the refactor and its response kept the old word, so the two twins + * disagreed on both halves of the name. + */ +const spec = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../openapi.json'), 'utf8')); + +/** The component a route answers with, unwrapping an array response to its item. */ +const responseComponent = (op: any): string | undefined => { + const schema = op?.responses?.['200']?.content?.['application/json']?.schema; + const ref = schema?.$ref ?? schema?.items?.$ref; + return ref?.replace('#/components/schemas/', ''); +}; + +const tradingRoutes = (type: 'amm' | 'clmm'): Array<[string, any]> => + Object.entries(spec.paths as Record) + .filter(([route]) => route.startsWith(`/trading/${type}/`)) + .flatMap(([route, ops]) => Object.values(ops as Record).map((op) => [route, op] as [string, any])); + +describe('every trading response is a named component', () => { + it.each([...tradingRoutes('amm'), ...tradingRoutes('clmm')].map(([route, op]) => [route, op]))( + '%s answers with a component', + (_route, op) => { + expect(responseComponent(op)).toBeDefined(); + }, + ); + + // Shared shapes are deliberately unprefixed: both surfaces really do answer a swap with + // the same body, and giving that two names would claim a difference that is not there. + const SHARED = ['ChainExecuteSwapResponse', 'ChainQuoteSwapResponse']; + + it('names an AMM response Amm… and its CLMM twin Clmm…', () => { + const byOperation = new Map(); + for (const type of ['amm', 'clmm'] as const) { + for (const [route, op] of tradingRoutes(type)) { + const operation = route.split('/').pop()!; + byOperation.set(operation, { ...byOperation.get(operation), [type]: responseComponent(op) }); + } + } + + const asymmetric: string[] = []; + for (const [operation, { amm, clmm }] of byOperation) { + if (!amm || !clmm) continue; + if (SHARED.includes(amm) && amm === clmm) continue; + if (!amm.startsWith('Amm') || !clmm.startsWith('Clmm')) { + asymmetric.push(`${operation}: ${amm} / ${clmm}`); + } + } + + expect(asymmetric).toEqual([]); + }); + + // A component holding a name its route no longer uses is the GW-10 trap. Pinned by name + // because the shapes are what they always were — only the labels were wrong. + it.each([ + ['QuotePositionResponse', 'the route is quote-liquidity now'], + ['PoolInfo', 'ambiguous between the two surfaces'], + ['PositionInfo', 'ambiguous between the two surfaces'], + ['AddLiquidityResponse', 'ambiguous between the two surfaces'], + ['CreatePoolResponse', 'ambiguous between the two surfaces'], + ])('no longer publishes %s (%s)', (name) => { + expect(Object.keys(spec.components.schemas)).not.toContain(name); + }); +}); diff --git a/test/spec/signing-routes-are-gated.test.ts b/test/spec/signing-routes-are-gated.test.ts new file mode 100644 index 0000000000..8adcda1e73 --- /dev/null +++ b/test/spec/signing-routes-are-gated.test.ts @@ -0,0 +1,76 @@ +import fs from 'fs'; +import path from 'path'; + +import { isSensitivePath } from '../../src/services/gateway-security'; + +// The API-token gate works off a hand-written list of path patterns, and a hand-written +// list drifts: /chains/* was left off it entirely, so an unauthenticated network request +// to POST /chains/ethereum/approve could have the hot wallet sign an unlimited allowance +// to any address — draining every ERC-20 it held — on a Gateway whose operator had set +// GATEWAY_API_KEY and every reason to believe it was closed. +// +// This derives the list that matters from the spec instead of restating it. A route that +// broadcasts a transaction answers with the identifier of the transaction it just signed; +// a route that merely reports on one is given that identifier by the caller. So: any +// operation that returns a `signature` it was not handed is a signing route, and every +// signing route must be behind the gate. Adding one and forgetting the pattern fails here. + +const spec = JSON.parse(fs.readFileSync(path.join(__dirname, '../../openapi.json'), 'utf8')); +const components = spec.components.schemas; + +const propertyNames = (schema: any, seen: string[] = []): Set => { + const found = new Set(); + if (!schema || typeof schema !== 'object') return found; + if (schema.$ref) { + const name = schema.$ref.split('/').pop(); + if (seen.includes(name)) return found; + return propertyNames(components[name], [...seen, name]); + } + for (const [key, value] of Object.entries(schema.properties ?? {})) { + found.add(key); + for (const nested of propertyNames(value, seen)) found.add(nested); + } + for (const key of ['items', 'allOf', 'anyOf', 'oneOf']) { + const value = (schema as any)[key]; + for (const entry of Array.isArray(value) ? value : [value]) { + for (const nested of propertyNames(entry, seen)) found.add(nested); + } + } + return found; +}; + +const responseProps = (operation: any) => + propertyNames(operation?.responses?.['200']?.content?.['application/json']?.schema); +const requestProps = (operation: any) => propertyNames(operation?.requestBody?.content?.['application/json']?.schema); + +const signingRoutes: string[] = []; +const reportingRoutes: string[] = []; +for (const [route, operations] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(operations)) { + if (method === 'parameters') continue; + if (!responseProps(operation).has('signature')) continue; + (requestProps(operation).has('signature') ? reportingRoutes : signingRoutes).push( + route.replace(/\{(\w+)\}/g, 'ethereum'), + ); + } +} + +describe('every route that signs a transaction is behind the API-token gate', () => { + it('finds signing routes to check', () => { + // Guards the guard: a spec that stopped naming `signature` would make this whole file + // vacuous, and it would still pass. + expect(signingRoutes.length).toBeGreaterThan(10); + }); + + it.each(signingRoutes)('gated: %s', (route) => { + expect(isSensitivePath(route)).toBe(true); + }); + + it('does not gate a route that only reports on a signature it was given', () => { + // /chains/{chain}/poll is how a bot follows a transaction it already sent. Gating it + // would break polling for a co-located bot without protecting anything: it signs + // nothing. + expect(reportingRoutes).toEqual(['/chains/ethereum/poll']); + expect(isSensitivePath('/chains/solana/poll')).toBe(false); + }); +}); diff --git a/test/tokens/tokens.routes.test.ts b/test/tokens/tokens.routes.test.ts index 1e307ac748..d9af72ca6b 100644 --- a/test/tokens/tokens.routes.test.ts +++ b/test/tokens/tokens.routes.test.ts @@ -1,5 +1,3 @@ -import Fastify, { FastifyInstance } from 'fastify'; - // Mock dependencies jest.mock('../../src/services/logger', () => ({ logger: { @@ -13,14 +11,17 @@ jest.mock('../../src/services/logger', () => ({ jest.mock('../../src/services/token-service'); // Import after mocking +import { FastifyInstance } from 'fastify'; + import { TokenService } from '../../src/services/token-service'; import { tokensRoutes } from '../../src/tokens/tokens.routes'; +import { fastifyWithTypeProvider } from '../utils/testUtils'; describe('Token Routes', () => { let app: FastifyInstance; beforeEach(async () => { - app = Fastify(); + app = fastifyWithTypeProvider(); await app.register(tokensRoutes); // Reset all mocks @@ -43,17 +44,14 @@ describe('Token Routes', () => { }); describe('GET /tokens', () => { - it('should return empty list when no chain/network specified', async () => { - const response = await app.inject({ - method: 'GET', - url: '/', - }); + it('rejects a request that names no chain-network, rather than answering nothing', async () => { + // This used to answer 200 with an empty list, which reads as "no tokens here" when + // it means "you did not say where". The data-management routes take no default, + // because guessing which stored list to read is not a convenience. + const response = await app.inject({ method: 'GET', url: '/' }); - expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); - expect(body).toEqual({ - tokens: [], - }); + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('chainNetwork'); }); it('should return tokens when chain and network specified', async () => { @@ -72,7 +70,7 @@ describe('Token Routes', () => { const response = await app.inject({ method: 'GET', - url: '/?chain=ethereum&network=mainnet', + url: '/?chainNetwork=ethereum-mainnet', }); expect(response.statusCode).toBe(200); @@ -95,7 +93,7 @@ describe('Token Routes', () => { const response = await app.inject({ method: 'GET', - url: '/?chain=ethereum&network=invalid', + url: '/?chainNetwork=ethereum-sepolia', }); expect(response.statusCode).toBe(404); @@ -117,15 +115,14 @@ describe('Token Routes', () => { const response = await app.inject({ method: 'GET', - url: '/USDC?chain=ethereum&network=mainnet', + url: '/USDC?chainNetwork=ethereum-mainnet', }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body).toEqual({ token: mockToken, - chain: 'ethereum', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', }); }); @@ -135,7 +132,7 @@ describe('Token Routes', () => { const response = await app.inject({ method: 'GET', - url: '/INVALID?chain=ethereum&network=mainnet', + url: '/INVALID?chainNetwork=ethereum-mainnet', }); expect(response.statusCode).toBe(404); @@ -159,8 +156,7 @@ describe('Token Routes', () => { method: 'POST', url: '/', payload: { - chain: 'ethereum', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', token: mockToken, }, }); @@ -175,8 +171,7 @@ describe('Token Routes', () => { method: 'POST', url: '/', payload: { - chain: 'ethereum', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', token: { symbol: 'TEST', // missing required fields @@ -203,8 +198,7 @@ describe('Token Routes', () => { method: 'POST', url: '/', payload: { - chain: 'ethereum', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', token: updatedToken, }, }); @@ -222,7 +216,7 @@ describe('Token Routes', () => { const response = await app.inject({ method: 'DELETE', - url: '/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48?chain=ethereum&network=mainnet', + url: '/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48?chainNetwork=ethereum-mainnet', }); expect(response.statusCode).toBe(200); @@ -238,7 +232,7 @@ describe('Token Routes', () => { const response = await app.inject({ method: 'DELETE', - url: '/0x123?chain=ethereum&network=mainnet', + url: '/0x123?chainNetwork=ethereum-mainnet', }); expect(response.statusCode).toBe(404); diff --git a/test/trading/chain-network-guard.test.ts b/test/trading/chain-network-guard.test.ts new file mode 100644 index 0000000000..eff5198313 --- /dev/null +++ b/test/trading/chain-network-guard.test.ts @@ -0,0 +1,154 @@ +import fs from 'fs'; +import path from 'path'; + +jest.mock('../../src/connectors/meteora/clmm-routes/addLiquidity', () => ({ + addLiquidity: jest.fn().mockResolvedValue({ signature: 'sig', status: 0 }), +})); +jest.mock('../../src/connectors/meteora/amm-routes/addLiquidity', () => ({ + addLiquidity: jest.fn().mockResolvedValue({ signature: 'sig', status: 0 }), +})); + +import { addLiquidity as meteoraAmmAddLiquidity } from '../../src/connectors/meteora/amm-routes/addLiquidity'; +import { addLiquidity as meteoraClmmAddLiquidity } from '../../src/connectors/meteora/clmm-routes/addLiquidity'; +import { SUPPORTED_CHAIN_NETWORKS } from '../../src/trading/common'; +import { tradingAmmRoutes, tradingClmmRoutes } from '../../src/trading/trading.routes'; +import { fastifyWithTypeProvider } from '../utils/testUtils'; + +/** + * The chain half of `chainNetwork` used to be decorative on the liquidity routes. + * + * They read only the network half and dispatched on the connector, so `ethereum-mainnet` + * ran a Solana connector against network `mainnet`, and a chain that exists nowhere ran + * it — successfully — against whatever followed the first hyphen. On `/add` or `/open` + * that submits a transaction for a request Gateway could have proved wrong. + * + * Two guards, because one does not imply the other: the enum rejects a selector that + * names no configured network, and the connector/chain check rejects a selector that + * names a real one belonging to the wrong chain. + */ +const buildApp = async (plugin: any, prefix: string) => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + await server.register(plugin, { prefix }); + return server; +}; + +const addPayload = (chainNetwork: string, connector = 'meteora') => ({ + connector, + chainNetwork, + walletAddress: 'W', + positionAddress: 'P', + baseTokenAmount: 1, +}); + +describe('the chain a liquidity route was given is the chain it acts on', () => { + let clmm: any; + let amm: any; + + beforeAll(async () => { + clmm = await buildApp(tradingClmmRoutes, '/trading/clmm'); + amm = await buildApp(tradingAmmRoutes, '/trading/amm'); + }); + + afterAll(async () => { + await clmm.close(); + await amm.close(); + }); + + beforeEach(() => { + (meteoraClmmAddLiquidity as jest.Mock).mockClear(); + (meteoraAmmAddLiquidity as jest.Mock).mockClear(); + }); + + // Not a hand-written list: the enum is read from the config namespaces, so this also + // fails if that lookup ever starts returning nothing and the enum silently empties. + it('publishes the configured chain-networks as the enum', () => { + expect(SUPPORTED_CHAIN_NETWORKS).toContain('solana-mainnet-beta'); + expect(SUPPORTED_CHAIN_NETWORKS).toContain('ethereum-mainnet'); + expect(SUPPORTED_CHAIN_NETWORKS.every((cn) => cn.includes('-'))).toBe(true); + }); + + describe.each([ + ['banana-mainnet-beta', 'a chain that does not exist'], + ['solana-', 'an empty network half'], + ['solana-nosuchnet', 'a network the chain is not configured for'], + ])('%s (%s)', (chainNetwork) => { + it('is rejected by the schema, and no connector runs', async () => { + const response = await clmm.inject({ + method: 'POST', + url: '/trading/clmm/add', + payload: addPayload(chainNetwork), + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('must be equal to one of the allowed values'); + expect(meteoraClmmAddLiquidity).not.toHaveBeenCalled(); + }); + }); + + it('rejects a Solana connector named with an Ethereum chain, before the connector runs', async () => { + const response = await clmm.inject({ + method: 'POST', + url: '/trading/clmm/add', + payload: addPayload('ethereum-mainnet'), + }); + + expect(response.statusCode).toBe(400); + // Names the chain it runs on and what to use instead, the same message the swap + // routes give for the same mistake — both come from the registry's lookup(). + expect(JSON.parse(response.body).message).toBe( + "Connector 'meteora' runs on solana, not ethereum. Use a ethereum clmm connector: uniswap, pancakeswap", + ); + expect(meteoraClmmAddLiquidity).not.toHaveBeenCalled(); + }); + + it('rejects the same mistake on the AMM surface', async () => { + const response = await amm.inject({ + method: 'POST', + url: '/trading/amm/add', + payload: { + connector: 'meteora', + chainNetwork: 'ethereum-mainnet', + walletAddress: 'W', + poolAddress: 'P', + baseTokenAmount: 1, + quoteTokenAmount: 1, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('runs on solana, not ethereum'); + expect(meteoraAmmAddLiquidity).not.toHaveBeenCalled(); + }); + + it('still dispatches a matching pair, passing the network half through', async () => { + const response = await clmm.inject({ + method: 'POST', + url: '/trading/clmm/add', + payload: addPayload('solana-mainnet-beta'), + }); + + expect(response.statusCode).toBe(200); + expect((meteoraClmmAddLiquidity as jest.Mock).mock.calls[0][0]).toBe('mainnet-beta'); + }); + + /** + * The guard above is on one route; the hole was on fifteen. This is what stops a + * sixteenth from reopening it: a route that names one connector for one pool must + * resolve its selector through resolveChainNetwork, which checks the pair. Calling + * parseChainNetwork and keeping only `network` is exactly the shape of the bug. + */ + it('leaves no liquidity route parsing its own chainNetwork', () => { + const dirs = ['src/trading/trading-amm-routes', 'src/trading/trading-clmm-routes']; + const offenders = dirs.flatMap((dir) => { + const abs = path.resolve(__dirname, '../..', dir); + return fs + .readdirSync(abs) + .filter((f) => f.endsWith('.ts') && f !== 'index.ts') + .filter((f) => fs.readFileSync(path.join(abs, f), 'utf8').includes('const { network } = parseChainNetwork(')) + .map((f) => `${dir}/${f}`); + }); + + expect(offenders).toEqual([]); + }); +}); diff --git a/test/trading/clmm/pool-info-bin-count.test.ts b/test/trading/clmm/pool-info-bin-count.test.ts new file mode 100644 index 0000000000..5e920772cc --- /dev/null +++ b/test/trading/clmm/pool-info-bin-count.test.ts @@ -0,0 +1,125 @@ +// binCount must reach the connector from the unified route. It was silently +// dropped: the querystring schema had no binCount and the connector calls passed +// only (fastify, network, poolAddress), so `bins` could never be returned through +// /trading/clmm/pool-info even for connectors that support it. + +const orcaGetPoolInfo = jest.fn(); +const raydiumGetPoolInfo = jest.fn(); +const uniswapGetPoolInfo = jest.fn(); +const pancakeswapGetPoolInfo = jest.fn(); +const meteoraGetPoolInfo = jest.fn(); +const pancakeswapSolGetPoolInfo = jest.fn(); + +jest.mock('../../../src/connectors/orca/clmm-routes/poolInfo', () => ({ getPoolInfo: orcaGetPoolInfo })); +jest.mock('../../../src/connectors/raydium/clmm-routes/poolInfo', () => ({ getPoolInfo: raydiumGetPoolInfo })); +jest.mock('../../../src/connectors/uniswap/clmm-routes/poolInfo', () => ({ getPoolInfo: uniswapGetPoolInfo })); +jest.mock('../../../src/connectors/pancakeswap/clmm-routes/poolInfo', () => ({ getPoolInfo: pancakeswapGetPoolInfo })); +jest.mock('../../../src/connectors/meteora/clmm-routes/poolInfo', () => ({ getPoolInfo: meteoraGetPoolInfo })); +jest.mock('../../../src/connectors/pancakeswap-sol/clmm-routes/poolInfo', () => ({ + getPoolInfo: pancakeswapSolGetPoolInfo, +})); + +import { poolsRoute } from '../../../src/trading/clmm/pools'; +import { fastifyWithTypeProvider } from '../../utils/testUtils'; +import { parseWire } from '../../utils/wire'; + +const POOL = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; + +const SAMPLE_POOL_INFO = { + address: POOL, + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.04, + price: 75.6, + baseTokenAmount: 1, + quoteTokenAmount: 2, + activeBinId: -25813, +}; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + await server.register(poolsRoute, { prefix: '/trading/clmm' }); + return server; +}; + +describe('Unified CLMM pool-info binCount passthrough', () => { + let app: any; + + beforeAll(async () => { + app = await buildApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + for (const m of [ + orcaGetPoolInfo, + raydiumGetPoolInfo, + uniswapGetPoolInfo, + pancakeswapGetPoolInfo, + meteoraGetPoolInfo, + pancakeswapSolGetPoolInfo, + ]) { + m.mockResolvedValue(SAMPLE_POOL_INFO); + } + }); + + const call = (connector: string, chainNetwork: string, binCount?: number) => + app.inject({ + method: 'GET', + url: '/trading/clmm/pool-info', + query: { + connector, + chainNetwork, + poolAddress: POOL, + ...(binCount === undefined ? {} : { binCount: String(binCount) }), + }, + }); + + it.each([ + ['orca', 'solana-mainnet-beta', () => orcaGetPoolInfo], + ['raydium', 'solana-mainnet-beta', () => raydiumGetPoolInfo], + ['uniswap', 'ethereum-mainnet', () => uniswapGetPoolInfo], + ['pancakeswap', 'ethereum-bsc', () => pancakeswapGetPoolInfo], + ])('forwards binCount to %s', async (connector, chainNetwork, getMock) => { + const response = await call(connector, chainNetwork, 11); + + expect(response.statusCode).toBe(200); + expect(getMock()).toHaveBeenCalledWith(expect.anything(), expect.any(String), POOL, 11); + }); + + it('defaults binCount to 0 when the caller omits it', async () => { + const response = await call('orca', 'solana-mainnet-beta'); + + expect(response.statusCode).toBe(200); + expect(orcaGetPoolInfo).toHaveBeenCalledWith(expect.anything(), expect.any(String), POOL, 0); + }); + + it('does not pass binCount to meteora, which always returns its bins', async () => { + const response = await call('meteora', 'solana-mainnet-beta', 11); + + expect(response.statusCode).toBe(200); + expect(meteoraGetPoolInfo).toHaveBeenCalledWith(expect.anything(), expect.any(String), POOL); + }); + + it('returns the bins the connector produced', async () => { + const bins = [{ binId: -25813, price: 75.6, baseTokenAmount: 1, quoteTokenAmount: 2 }]; + orcaGetPoolInfo.mockResolvedValue({ ...SAMPLE_POOL_INFO, bins }); + + const response = await call('orca', 'solana-mainnet-beta', 1); + + expect(response.statusCode).toBe(200); + expect(parseWire(response.body).bins).toEqual(bins); + }); + + it('rejects a binCount above the supported maximum', async () => { + const response = await call('orca', 'solana-mainnet-beta', 500); + + expect(response.statusCode).toBe(400); + expect(orcaGetPoolInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/test/trading/clmm/routes.test.ts b/test/trading/clmm/routes.test.ts index 081f7b0b5e..529a65712b 100644 --- a/test/trading/clmm/routes.test.ts +++ b/test/trading/clmm/routes.test.ts @@ -19,90 +19,54 @@ describe('Unified Trading CLMM Routes', () => { await app.close(); }); + // These assert registration two ways, because the old form — send an empty body and + // accept [400, 500] — asserted neither. hasRoute answers "is this route registered" + // exactly and without a request; the follow-up injection shows the route's schema is + // attached and running, since a validation rejection can only come from a matched + // route. Accepting a 500 meant a route that crashed on every request still passed. describe('Route Registration', () => { - it('should register POST /trading/clmm/open route', async () => { - const response = await app.inject({ - method: 'POST', - url: '/trading/clmm/open', - payload: {}, - }); - - // Should return 400 for missing required fields, not 404 - expect([400, 500]).toContain(response.statusCode); - }); - - it('should register POST /trading/clmm/add route', async () => { - const response = await app.inject({ - method: 'POST', - url: '/trading/clmm/add', - payload: {}, - }); - - expect([400, 500]).toContain(response.statusCode); - }); - - it('should register POST /trading/clmm/remove route', async () => { - const response = await app.inject({ - method: 'POST', - url: '/trading/clmm/remove', - payload: {}, - }); - - expect([400, 500]).toContain(response.statusCode); - }); - - it('should register POST /trading/clmm/collect-fees route', async () => { - const response = await app.inject({ - method: 'POST', - url: '/trading/clmm/collect-fees', - payload: {}, - }); + const REQUIRE_A_FIELD = [ + { method: 'POST', url: '/trading/clmm/open', missing: 'lowerPrice' }, + { method: 'POST', url: '/trading/clmm/add', missing: 'positionAddress' }, + { method: 'POST', url: '/trading/clmm/remove', missing: 'positionAddress' }, + { method: 'POST', url: '/trading/clmm/collect-fees', missing: 'positionAddress' }, + { method: 'POST', url: '/trading/clmm/close', missing: 'positionAddress' }, + { method: 'GET', url: '/trading/clmm/pool-info', missing: 'poolAddress' }, + { method: 'GET', url: '/trading/clmm/position-info', missing: 'positionAddress' }, + ] as const; + + // The write routes name their connector: it lost its schema default, because AJV + // injects defaults before the handler and "whichever connector is first in the + // registry" is not an answer to "which venue?" on a request that signs. So the + // request under test carries a connector and omits only the field being checked. + it.each(REQUIRE_A_FIELD)('registers $method $url and validates its input', async ({ method, url, missing }) => { + expect(app.hasRoute({ method, url })).toBe(true); + + const request = { connector: 'meteora' }; + const response = await app.inject( + method === 'GET' ? { method, url, query: request } : { method, url, payload: request }, + ); - expect([400, 500]).toContain(response.statusCode); - }); - - it('should register POST /trading/clmm/close route', async () => { - const response = await app.inject({ - method: 'POST', - url: '/trading/clmm/close', - payload: {}, - }); - - expect([400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.code).toBe('FST_ERR_VALIDATION'); + expect(body.message).toContain(`must have required property '${missing}'`); }); - it('should register GET /trading/clmm/pool-info route', async () => { - const response = await app.inject({ - method: 'GET', - url: '/trading/clmm/pool-info', - query: {}, - }); + it('will not pick a venue for a write whose caller did not name one', async () => { + const response = await app.inject({ method: 'POST', url: '/trading/clmm/close', payload: {} }); - expect([400, 500]).toContain(response.statusCode); + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain("must have required property 'connector'"); }); - it('should register GET /trading/clmm/position-info route', async () => { - const response = await app.inject({ - method: 'GET', - url: '/trading/clmm/position-info', - query: {}, - }); - - expect([400, 500]).toContain(response.statusCode); - }); - - it('should register GET /trading/clmm/positions-owned route', async () => { - const response = await app.inject({ - method: 'GET', - url: '/trading/clmm/positions-owned', - query: { - connector: 'meteora', - chainNetwork: 'solana-mainnet-beta', - }, - }); - - // Route should exist and return 200 or 400/500 (requires wallet address) - expect([200, 400, 500]).toContain(response.statusCode); + // positions-owned is the one route here with no required field — connector, + // chainNetwork and walletAddress all carry schema defaults — so there is no + // validation rejection to observe and hasRoute is the whole assertion. Injecting a + // request instead would reach a live connector, which is what made the old version + // of this case hedge across [200, 400, 500]. + it('registers GET /trading/clmm/positions-owned', () => { + expect(app.hasRoute({ method: 'GET', url: '/trading/clmm/positions-owned' })).toBe(true); }); }); @@ -113,7 +77,7 @@ describe('Unified Trading CLMM Routes', () => { method: 'POST', url: '/trading/clmm/open', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', lowerPrice: 1800, upperPrice: 2200, @@ -146,7 +110,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/open', payload: { connector: 'uniswap', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', lowerPrice: 1800, upperPrice: 2200, poolAddress: '0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8', @@ -178,7 +142,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/remove', payload: { connector: 'uniswap', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', percentageToRemove: 50, }, @@ -265,7 +229,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/open', payload: { connector: 'uniswap', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', lowerPrice: 1800, upperPrice: 2200, @@ -301,7 +265,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/remove', payload: { connector: 'raydium', - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', walletAddress: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', positionAddress: 'position123', percentageToRemove: 50, @@ -317,7 +281,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/collect-fees', payload: { connector: 'meteora', - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', walletAddress: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', positionAddress: 'position456', }, @@ -337,7 +301,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/close', payload: { connector: 'pancakeswap-sol', - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', walletAddress: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', positionAddress: 'position789', }, @@ -352,17 +316,21 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/open', payload: { connector: 'invalid-connector', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', lowerPrice: 1800, upperPrice: 2200, poolAddress: '0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8', + // An otherwise-valid body: amount validation runs before connector + // routing, and this test is about the connector rejection. + baseTokenAmount: 1, }, }); expect(response.statusCode).toBe(400); const body = JSON.parse(response.body); - expect(body.message).toContain('Unsupported connector'); + // The connector field is enum-constrained, so rejection happens at schema validation. + expect(body.message).toContain('must be equal to one of the allowed values'); }); }); }); diff --git a/test/trading/intent-is-not-dropped.test.ts b/test/trading/intent-is-not-dropped.test.ts new file mode 100644 index 0000000000..87eac77da0 --- /dev/null +++ b/test/trading/intent-is-not-dropped.test.ts @@ -0,0 +1,160 @@ +import Fastify, { FastifyInstance } from 'fastify'; + +import { ajvOptions, schemaErrorFormatter } from '../../src/services/schema-keywords'; +import { tradingClmmRoutes, tradingAmmRoutes } from '../../src/trading/trading.routes'; + +// Three ways a request used to be accepted and then acted on differently, all of them +// silent. None returned a wrong answer; each did something other than what was asked. + +jest.mock('../../src/chains/solana/solana'); +jest.mock('../../src/chains/ethereum/ethereum'); + +const buildApp = async (): Promise => { + const app = Fastify({ ajv: ajvOptions as any, schemaErrorFormatter }); + await app.register(require('@fastify/sensible')); + // The same hook app.ts installs. Kept here rather than booting the whole server so the + // test exercises the rule and not the RPC layer. + app.addHook('preValidation', async (request) => { + const schema = (request as any).routeOptions?.schema; + if (!schema) return; + for (const [part, sent] of [ + [schema.body, request.body], + [schema.querystring, request.query], + ] as [any, any][]) { + if (!part?.properties || !sent || typeof sent !== 'object') continue; + const connector = sent.connector ?? part.properties.connector?.default; + if (!connector) continue; + for (const [field, spec] of Object.entries(part.properties)) { + const connectors: string[] | undefined = spec?.['x-connectors']; + if (!connectors || sent[field] === undefined) continue; + if (!connectors.includes(connector)) { + throw app.httpErrors.badRequest(`${field} is not a ${connector} parameter`); + } + } + } + }); + await app.register(tradingClmmRoutes, { prefix: '/trading/clmm' }); + await app.register(tradingAmmRoutes, { prefix: '/trading/amm' }); + await app.ready(); + return app; +}; + +let app: FastifyInstance; +beforeAll(async () => { + app = await buildApp(); +}); +afterAll(async () => { + await app.close(); +}); + +describe('a key the route does not declare is rejected, not dropped', () => { + it('rejects a typo in a field that would change the trade', async () => { + // `slippagePc: 5` was accepted and dropped, and the trade went out at the + // connector's configured slippage — the caller's stated tolerance, silently ignored. + const response = await app.inject({ + method: 'POST', + url: '/trading/clmm/close', + payload: { + connector: 'meteora', + chainNetwork: 'solana-mainnet-beta', + walletAddress: 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD', + positionAddress: 'ARwi1S4DaiTG5DX7S4M4ZsrXqpMD1MrTmbu9ue2tpmEq', + slippagePc: 5, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/slippagePc/); + }); + + it('still accepts the field spelled correctly', async () => { + const response = await app.inject({ + method: 'POST', + url: '/trading/clmm/close', + payload: { + connector: 'meteora', + chainNetwork: 'solana-mainnet-beta', + walletAddress: 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD', + positionAddress: 'ARwi1S4DaiTG5DX7S4M4ZsrXqpMD1MrTmbu9ue2tpmEq', + slippagePct: 5, + }, + }); + + // Past validation: whatever happens next is the connector's business, not the + // schema's. + expect(response.statusCode).not.toBe(400); + }); +}); + +describe('x-connectors is enforced, not decorative', () => { + it('rejects a meteora-only field sent to raydium', async () => { + // `configAddress` is meteora's; passing it to raydium used to create a pool with + // raydium's defaults instead of erroring. + const response = await app.inject({ + method: 'POST', + url: '/trading/amm/create-pool', + payload: { + connector: 'raydium', + chainNetwork: 'solana-mainnet-beta', + walletAddress: 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD', + baseToken: 'SOL', + quoteToken: 'USDC', + baseTokenAmount: 1, + quoteTokenAmount: 100, + configAddress: 'Ai7fXNPLhUXm3Q8m9pKJDLWNPvKvNKcNPPWQ9pQqcXJm', + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/configAddress is not a raydium parameter/); + }); + + it('accepts the same field on the connector it belongs to', async () => { + const response = await app.inject({ + method: 'POST', + url: '/trading/amm/create-pool', + payload: { + connector: 'meteora', + chainNetwork: 'solana-mainnet-beta', + walletAddress: 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD', + baseToken: 'SOL', + quoteToken: 'USDC', + baseTokenAmount: 1, + quoteTokenAmount: 100, + configAddress: 'Ai7fXNPLhUXm3Q8m9pKJDLWNPvKvNKcNPPWQ9pQqcXJm', + }, + }); + + expect(response.statusCode).not.toBe(400); + }); +}); + +describe('a write does not pick a venue for you', () => { + it('requires the connector rather than defaulting to the first in the registry', async () => { + const response = await app.inject({ + method: 'POST', + url: '/trading/clmm/open', + payload: { + chainNetwork: 'solana-mainnet-beta', + walletAddress: 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD', + poolAddress: 'ARwi1S4DaiTG5DX7S4M4ZsrXqpMD1MrTmbu9ue2tpmEq', + lowerPrice: 100, + upperPrice: 200, + baseTokenAmount: 1, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/must have required property 'connector'/); + }); + + it('leaves the reads their default, which is what fills the Swagger form', async () => { + const response = await app.inject({ + method: 'GET', + url: '/trading/clmm/positions-owned', + query: { chainNetwork: 'solana-mainnet-beta', walletAddress: 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD' }, + }); + + expect(response.statusCode).not.toBe(400); + }); +}); diff --git a/test/trading/pool-swap/learns-pool-and-tokens.test.ts b/test/trading/pool-swap/learns-pool-and-tokens.test.ts new file mode 100644 index 0000000000..a88f37a258 --- /dev/null +++ b/test/trading/pool-swap/learns-pool-and-tokens.test.ts @@ -0,0 +1,164 @@ +/** + * A swap teaches Gateway the pool and tokens it ran against. + * + * The service that does the recording is covered in + * test/services/token-pool-autosave.test.ts. What these assert is that the trading + * routes actually reach it, and reach it with the pool they really used — a pinned + * address rather than the pair the caller named, which is the whole reason the pin + * exists and the only thing worth recording. + */ +const mockEnsurePoolSaved = jest.fn().mockResolvedValue(undefined); +const mockEnsureTokenSaved = jest.fn().mockResolvedValue(null); + +// Only the two recording functions are stubbed. recordQuietly stays real, because the +// guarantee it makes — that a failed write cannot fail the request — is one of the things +// under test here, and a stub of it would assert itself. +jest.mock('../../../src/services/token-pool-autosave', () => ({ + ...jest.requireActual('../../../src/services/token-pool-autosave'), + ensurePoolSaved: (...args: any[]) => mockEnsurePoolSaved(...args), + ensureTokenSaved: (...args: any[]) => mockEnsureTokenSaved(...args), +})); + +const mockGetPool = jest.fn(); +jest.mock('../../../src/services/pool-service', () => ({ + PoolService: { getInstance: () => ({ getPool: (...args: any[]) => mockGetPool(...args) }) }, +})); + +const mockMeteoraClmmQuoteSwap = jest.fn(); +jest.mock('../../../src/connectors/meteora/clmm-routes/quoteSwap', () => ({ + quoteSwap: (...args: any[]) => mockMeteoraClmmQuoteSwap(...args), +})); + +const mockJupiterRouterQuoteSwap = jest.fn(); +jest.mock('../../../src/connectors/jupiter/router-routes/quoteSwap', () => ({ + quoteSwap: (...args: any[]) => mockJupiterRouterQuoteSwap(...args), +})); + +jest.mock('../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../src/chains/solana/solana.config'), + getSolanaNetworkConfig: () => ({ swapProvider: 'jupiter/router' }), +})); + +import { tradingClmmRoutes, tradingRouterRoutes } from '../../../src/trading/trading.routes'; +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +const PINNED_POOL = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; +const SOL = 'So11111111111111111111111111111111111111112'; +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +const POOL_QUOTE = { + poolAddress: PINNED_POOL, + tokenIn: SOL, + tokenOut: USDC, + amountIn: 1, + amountOut: 100, + price: 100, + minAmountOut: 99, + maxAmountIn: 1, + priceImpactPct: 0.1, +}; + +const ROUTER_QUOTE = { ...POOL_QUOTE, quoteId: 'quote-1', poolAddress: undefined }; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + await server.register(tradingClmmRoutes, { prefix: '/trading/clmm' }); + await server.register(tradingRouterRoutes, { prefix: '/trading/router' }); + return server; +}; + +const url = (base: string, params: Record) => `${base}?${new URLSearchParams(params).toString()}`; + +describe('a swap records what it traded against', () => { + let app: any; + + beforeAll(async () => { + app = await buildApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockEnsurePoolSaved.mockResolvedValue(undefined); + mockEnsureTokenSaved.mockResolvedValue(null); + }); + + it('records the pinned pool a CLMM quote used, not the pair it was asked for', async () => { + mockMeteoraClmmQuoteSwap.mockResolvedValue(POOL_QUOTE); + + const response = await app.inject({ + method: 'GET', + url: url('/trading/clmm/quote-swap', { + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + poolAddress: PINNED_POOL, + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockEnsurePoolSaved).toHaveBeenCalledWith( + expect.objectContaining({ + chain: 'solana', + network: 'mainnet-beta', + connector: 'meteora', + type: 'clmm', + poolAddress: PINNED_POOL, + }), + ); + // The pool list was never consulted for a pair, because a pin skips that lookup. + expect(mockGetPool).not.toHaveBeenCalled(); + }); + + // A router picks its own path across pools, so there is no pool to record — only the + // two tokens the caller named, which is the one chance to learn an unlisted address. + it('records both tokens of a router quote, and no pool', async () => { + mockJupiterRouterQuoteSwap.mockResolvedValue(ROUTER_QUOTE); + + const response = await app.inject({ + method: 'GET', + url: url('/trading/router/quote-swap', { + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', + baseToken: SOL, + quoteToken: USDC, + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockEnsureTokenSaved).toHaveBeenCalledWith('solana', 'mainnet-beta', SOL); + expect(mockEnsureTokenSaved).toHaveBeenCalledWith('solana', 'mainnet-beta', USDC); + expect(mockEnsurePoolSaved).not.toHaveBeenCalled(); + }); + + // Recording is bookkeeping. A quote that priced correctly has answered the caller's + // question, and must not be reported as failed because the write behind it did not. + it('answers the caller even when recording fails', async () => { + mockMeteoraClmmQuoteSwap.mockResolvedValue(POOL_QUOTE); + mockEnsurePoolSaved.mockRejectedValue(new Error('pool list unwritable')); + + const response = await app.inject({ + method: 'GET', + url: url('/trading/clmm/quote-swap', { + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + poolAddress: PINNED_POOL, + }), + }); + + expect(response.statusCode).toBe(200); + }); +}); diff --git a/test/trading/pool-swap/pool-address-pin.test.ts b/test/trading/pool-swap/pool-address-pin.test.ts new file mode 100644 index 0000000000..d9ece2b320 --- /dev/null +++ b/test/trading/pool-swap/pool-address-pin.test.ts @@ -0,0 +1,208 @@ +import { tradingClmmRoutes, tradingRouterRoutes } from '../../../src/trading/trading.routes'; +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +// The pool-scoped swap routes resolve a pool from Gateway's configured pool list +// by token pair. A pool that is not in that list — a freshly created one, or one +// on an unlisted token — is unreachable that way, so callers can pin it by address. +// The router surface has no pool to pin: it picks its own route across pools, which +// is now expressed by /trading/router carrying no poolAddress parameter at all. + +const mockGetPool = jest.fn(); + +jest.mock('../../../src/services/pool-service', () => ({ + PoolService: { + getInstance: () => ({ + getPool: (...args: any[]) => mockGetPool(...args), + }), + }, +})); + +const mockMeteoraClmmQuoteSwap = jest.fn(); +jest.mock('../../../src/connectors/meteora/clmm-routes/quoteSwap', () => ({ + quoteSwap: (...args: any[]) => mockMeteoraClmmQuoteSwap(...args), +})); + +const mockJupiterRouterQuoteSwap = jest.fn(); +jest.mock('../../../src/connectors/jupiter/router-routes/quoteSwap', () => ({ + quoteSwap: (...args: any[]) => mockJupiterRouterQuoteSwap(...args), +})); + +jest.mock('../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../src/chains/solana/solana.config'), + getSolanaNetworkConfig: () => ({ swapProvider: 'jupiter/router' }), +})); + +const PINNED_POOL = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; + +const QUOTE = { + // Router quotes carry the id that /trading/router/execute-quote takes; the + // response schema requires it, so a quote without one fails serialization. + quoteId: 'quote-1', + tokenIn: 'So11111111111111111111111111111111111111112', + tokenOut: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amountIn: 1, + amountOut: 100, + price: 100, + minAmountOut: 99, + maxAmountIn: 1, + priceImpactPct: 0.1, +}; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + await server.register(tradingClmmRoutes, { prefix: '/trading/clmm' }); + await server.register(tradingRouterRoutes, { prefix: '/trading/router' }); + return server; +}; + +const url = (base: string, params: Record) => `${base}?${new URLSearchParams(params).toString()}`; + +const clmmQuote = (params: Record) => url('/trading/clmm/quote-swap', params); +const routerQuote = (params: Record) => url('/trading/router/quote-swap', params); + +describe('Pool-scoped swap quote — poolAddress pin', () => { + let app: any; + + beforeAll(async () => { + app = await buildApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockMeteoraClmmQuoteSwap.mockResolvedValue(QUOTE); + mockJupiterRouterQuoteSwap.mockResolvedValue(QUOTE); + }); + + it('uses the pinned pool without consulting the configured pool list', async () => { + const response = await app.inject({ + method: 'GET', + url: clmmQuote({ + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + poolAddress: PINNED_POOL, + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockGetPool).not.toHaveBeenCalled(); + expect(mockMeteoraClmmQuoteSwap).toHaveBeenCalledWith('mainnet-beta', PINNED_POOL, 'SOL', 'SELL', 1, undefined); + }); + + it('falls back to the configured pool list when no pin is given', async () => { + mockGetPool.mockResolvedValue({ address: PINNED_POOL }); + + const response = await app.inject({ + method: 'GET', + url: clmmQuote({ + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockGetPool).toHaveBeenCalledWith('solana', 'mainnet-beta', 'clmm', 'SOL', 'USDC', 'meteora'); + }); + + it('tells an unresolvable pair that a pin is available', async () => { + mockGetPool.mockResolvedValue(null); + + const response = await app.inject({ + method: 'GET', + url: clmmQuote({ + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora', + baseToken: 'NEWMINT', + quoteToken: 'SOL', + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toContain('poolAddress'); + }); + + // The type now lives in the path, so `connector` is a bare, enum-constrained name. + // The old "connector/type" form is rejected at the schema rather than tolerated: + // an enum keeps the accepted set in the spec, which is what a generated client reads. + it.each(['meteora/clmm', 'jupiter/router'])('rejects the old connector/type form (%s)', async (connector) => { + const response = await app.inject({ + method: 'GET', + url: clmmQuote({ + chainNetwork: 'solana-mainnet-beta', + connector, + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + poolAddress: PINNED_POOL, + }), + }); + + expect(response.statusCode).toBe(400); + expect(response.json().message).toContain('connector'); + }); + + it('rejects a connector that is not a CLMM connector', async () => { + const response = await app.inject({ + method: 'GET', + url: clmmQuote({ + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(400); + }); + + it('routes through the router surface without ever consulting the pool list', async () => { + const response = await app.inject({ + method: 'GET', + url: routerQuote({ + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockGetPool).not.toHaveBeenCalled(); + expect(mockJupiterRouterQuoteSwap).toHaveBeenCalled(); + }); + + it("uses the network's configured swapProvider when no connector is named", async () => { + const response = await app.inject({ + method: 'GET', + url: routerQuote({ + chainNetwork: 'solana-mainnet-beta', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockJupiterRouterQuoteSwap).toHaveBeenCalled(); + }); +}); diff --git a/test/trading/response-identifiers.test.ts b/test/trading/response-identifiers.test.ts new file mode 100644 index 0000000000..5ca33f3f5f --- /dev/null +++ b/test/trading/response-identifiers.test.ts @@ -0,0 +1,196 @@ +import { fastifyWithTypeProvider } from '../utils/testUtils'; + +// Every fund-moving response should name the pool — and, where one exists, the position +// — it acted on, so a stored record identifies its venue without the request that +// produced it. The AMM routes take the pool in the request and echo it. The CLMM write +// routes are position-addressed and never receive one, so the connector reports the pool +// it already loaded, and the route only adds the position the caller named. + +const POOL = 'FAKEpoolAddress1111111111111111111111111111'; +const POSITION = 'FAKEpositionAddress11111111111111111111111'; +const WALLET = '82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5'; + +const CONFIRMED_ADD = { + signature: 'sig', + status: 1, + data: { fee: 0.00001, baseTokenAmountAdded: 1, quoteTokenAmountAdded: 2 }, +}; +const mockRaydiumAmmAdd = jest.fn(); +const mockRaydiumAmmRemove = jest.fn(); +const mockMeteoraAmmClose = jest.fn(); +const mockMeteoraClmmClose = jest.fn(); + +jest.mock('../../src/connectors/raydium/amm-routes/addLiquidity', () => ({ + addLiquidity: (...a: any[]) => mockRaydiumAmmAdd(...a), +})); +jest.mock('../../src/connectors/raydium/amm-routes/removeLiquidity', () => ({ + removeLiquidity: (...a: any[]) => mockRaydiumAmmRemove(...a), +})); +jest.mock('../../src/connectors/meteora/amm-routes/openPosition', () => ({ + openPosition: jest.fn(), +})); +jest.mock('../../src/connectors/meteora/amm-routes/closePosition', () => ({ + closePosition: (...a: any[]) => mockMeteoraAmmClose(...a), +})); +jest.mock('../../src/connectors/meteora/clmm-routes/closePosition', () => ({ + closePosition: (...a: any[]) => mockMeteoraClmmClose(...a), +})); + +const buildAmm = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { addLiquidityRoute } = await import('../../src/trading/trading-amm-routes/add'); + const { removeLiquidityRoute } = await import('../../src/trading/trading-amm-routes/remove'); + await server.register(addLiquidityRoute); + await server.register(removeLiquidityRoute); + return server; +}; + +const buildClmm = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { closePositionRoute } = await import('../../src/trading/trading-clmm-routes/close'); + await server.register(closePositionRoute); + return server; +}; + +describe('write responses name what they acted on', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('AMM — identifiers come from the request', () => { + let server: any; + beforeAll(async () => { + server = await buildAmm(); + }); + afterAll(async () => server.close()); + + it('stamps the pool on a fungible-LP add, with no position to name', async () => { + mockRaydiumAmmAdd.mockResolvedValue(CONFIRMED_ADD); + + const response = await server.inject({ + method: 'POST', + url: '/add', + payload: { + connector: 'raydium', + chainNetwork: 'solana-mainnet-beta', + walletAddress: WALLET, + poolAddress: POOL, + baseTokenAmount: 1, + quoteTokenAmount: 2, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.poolAddress).toBe(POOL); + expect(response.json().data.positionAddress).toBeUndefined(); + }); + + // A full removal closes the position account, so it routes through closePosition — + // hence the close mock on a /remove request. + it('stamps both on a meteora full removal, whose position the caller named', async () => { + mockMeteoraAmmClose.mockResolvedValue({ + signature: 'sig', + status: 1, + data: { fee: 0.00001, positionRentRefunded: 0.0575, baseTokenAmountRemoved: 1, quoteTokenAmountRemoved: 2 }, + }); + + const response = await server.inject({ + method: 'POST', + url: '/remove', + payload: { + connector: 'meteora', + chainNetwork: 'solana-mainnet-beta', + walletAddress: WALLET, + poolAddress: POOL, + positionAddress: POSITION, + percentageToRemove: 100, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toMatchObject({ poolAddress: POOL, positionAddress: POSITION }); + }); + + it('does not stamp a pending transaction, which has no confirmed data to describe', async () => { + mockRaydiumAmmRemove.mockResolvedValue({ signature: 'sig-pending', status: 0 }); + + const response = await server.inject({ + method: 'POST', + url: '/remove', + payload: { + connector: 'raydium', + chainNetwork: 'solana-mainnet-beta', + walletAddress: WALLET, + poolAddress: POOL, + percentageToRemove: 50, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ signature: 'sig-pending', status: 0 }); + }); + }); + + describe('CLMM — the pool comes from the connector', () => { + let server: any; + beforeAll(async () => { + server = await buildClmm(); + }); + afterAll(async () => server.close()); + + const closed = (poolAddress?: string) => ({ + signature: 'sig', + status: 1, + data: { + fee: 0.00001, + ...(poolAddress ? { poolAddress } : {}), + positionRentRefunded: 0.002, + baseTokenAmountRemoved: 1, + quoteTokenAmountRemoved: 2, + baseFeeAmountCollected: 0, + quoteFeeAmountCollected: 0, + }, + }); + + const close = () => + server.inject({ + method: 'POST', + url: '/close', + payload: { + connector: 'meteora', + chainNetwork: 'solana-mainnet-beta', + walletAddress: WALLET, + positionAddress: POSITION, + }, + }); + + it('keeps the pool the connector reported and adds the position the caller named', async () => { + mockMeteoraClmmClose.mockResolvedValue(closed(POOL)); + + const response = await close(); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toMatchObject({ poolAddress: POOL, positionAddress: POSITION }); + }); + + it('does not invent a pool the connector did not report', async () => { + // The route never receives a pool, so it has nothing of its own to fall back on. + mockMeteoraClmmClose.mockResolvedValue(closed()); + + const response = await close(); + + expect(response.statusCode).toBe(200); + expect(response.json().data.poolAddress).toBeUndefined(); + expect(response.json().data.positionAddress).toBe(POSITION); + }); + + it('does not stamp a pending close, which has no confirmed data to describe', async () => { + mockMeteoraClmmClose.mockResolvedValue({ signature: 'sig-pending', status: 0 }); + + const response = await close(); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ signature: 'sig-pending', status: 0 }); + }); + }); +}); diff --git a/test/trading/trading-amm-routes/create-pool.test.ts b/test/trading/trading-amm-routes/create-pool.test.ts index a100a49ba3..4de0d8ea1a 100644 --- a/test/trading/trading-amm-routes/create-pool.test.ts +++ b/test/trading/trading-amm-routes/create-pool.test.ts @@ -34,6 +34,6 @@ describe('POST /trading/amm/create-pool (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); }); diff --git a/test/trading/trading-amm-routes/positions-owned.test.ts b/test/trading/trading-amm-routes/positions-owned.test.ts index 73504851cb..d7778783c9 100644 --- a/test/trading/trading-amm-routes/positions-owned.test.ts +++ b/test/trading/trading-amm-routes/positions-owned.test.ts @@ -21,18 +21,22 @@ describe('GET /trading/amm/positions-owned (unified dispatch)', () => { await server.close(); }); - it.each(['raydium', 'uniswap', 'pancakeswap'])( - 'rejects %s: fungible-LP AMMs have no enumerable positions', - async (connector) => { - const response = await server.inject({ - method: 'GET', - url: `/positions-owned?connector=${connector}&chainNetwork=solana-mainnet-beta&walletAddress=${WALLET}`, - }); - - expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/not supported for .*fungible-LP/); - }, - ); + // Each connector is asked for on the chain it actually runs on. Naming a Solana + // chain-network for an Ethereum connector is now rejected as a mismatched pair, which + // is a different rejection than the one these cases are about. + it.each([ + ['raydium', 'solana-mainnet-beta'], + ['uniswap', 'ethereum-mainnet'], + ['pancakeswap', 'ethereum-mainnet'], + ])('rejects %s: fungible-LP AMMs have no enumerable positions', async (connector, chainNetwork) => { + const response = await server.inject({ + method: 'GET', + url: `/positions-owned?connector=${connector}&chainNetwork=${chainNetwork}&walletAddress=${WALLET}`, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/not supported for .*fungible-LP/); + }); it('rejects an unsupported AMM connector', async () => { const response = await server.inject({ @@ -41,6 +45,6 @@ describe('GET /trading/amm/positions-owned (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); }); diff --git a/test/trading/trading-amm-routes/remove-liquidity.test.ts b/test/trading/trading-amm-routes/remove.test.ts similarity index 85% rename from test/trading/trading-amm-routes/remove-liquidity.test.ts rename to test/trading/trading-amm-routes/remove.test.ts index 26f6791b4f..0402d331b6 100644 --- a/test/trading/trading-amm-routes/remove-liquidity.test.ts +++ b/test/trading/trading-amm-routes/remove.test.ts @@ -3,12 +3,12 @@ import { fastifyWithTypeProvider } from '../../utils/testUtils'; const buildApp = async () => { const server = fastifyWithTypeProvider(); await server.register(require('@fastify/sensible')); - const { removeLiquidityRoute } = await import('../../../src/trading/trading-amm-routes/remove-liquidity'); + const { removeLiquidityRoute } = await import('../../../src/trading/trading-amm-routes/remove'); await server.register(removeLiquidityRoute); return server; }; -describe('POST /trading/amm/remove-liquidity (unified dispatch)', () => { +describe('POST /trading/amm/remove (unified dispatch)', () => { let server: any; beforeAll(async () => { @@ -22,7 +22,7 @@ describe('POST /trading/amm/remove-liquidity (unified dispatch)', () => { it('requires positionAddress for meteora (DAMM v2 positions are NFTs)', async () => { const response = await server.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { connector: 'meteora', chainNetwork: 'solana-mainnet-beta', @@ -39,7 +39,7 @@ describe('POST /trading/amm/remove-liquidity (unified dispatch)', () => { it('rejects an unsupported AMM connector', async () => { const response = await server.inject({ method: 'POST', - url: '/remove-liquidity', + url: '/remove', payload: { connector: 'notaconnector', chainNetwork: 'solana-mainnet-beta', @@ -50,6 +50,6 @@ describe('POST /trading/amm/remove-liquidity (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); }); diff --git a/test/trading/trading-clmm-routes/create-pool.test.ts b/test/trading/trading-clmm-routes/create-pool.test.ts index 4c1da80f74..0871f879a8 100644 --- a/test/trading/trading-clmm-routes/create-pool.test.ts +++ b/test/trading/trading-clmm-routes/create-pool.test.ts @@ -33,6 +33,6 @@ describe('POST /trading/clmm/create-pool (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported CLMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); }); diff --git a/test/utils/testUtils.ts b/test/utils/testUtils.ts index 050c351bcd..c7ca06b7e7 100644 --- a/test/utils/testUtils.ts +++ b/test/utils/testUtils.ts @@ -1,6 +1,10 @@ import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import Fastify from 'fastify'; +import { ajvOptions } from '../../src/services/schema-keywords'; + +// Mirrors the app's AJV configuration so schemas that validate in production +// (including the x- vendor extensions) also validate under test. export const fastifyWithTypeProvider = () => { - return Fastify().withTypeProvider(); + return Fastify({ ajv: ajvOptions }).withTypeProvider(); }; diff --git a/test/utils/wire.ts b/test/utils/wire.ts new file mode 100644 index 0000000000..a5d702477b --- /dev/null +++ b/test/utils/wire.ts @@ -0,0 +1,42 @@ +/** + * Parse a Gateway response the way a caller with a decimal type does. + * + * Money crosses the wire as a decimal STRING — `"0.000037"`, not `0.000037` — because a + * JSON number is an IEEE 754 double and an exact on-chain decimal does not survive one. + * The generated clients map those fields to `Decimal`, so a caller compares quantities, + * not representations. + * + * These tests assert quantities too, so this converts a numeric string back into a + * number — except where the field is an identifier. A Uniswap position is addressed by + * its NFT token id, which is digits and must stay a string; so is a numeric-looking + * signature or pool address. Those are named rather than guessed. + */ +const NUMERIC = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; + +/** Fields whose value identifies something, however numeric it looks. */ +const IDENTIFIERS = new Set([ + 'address', + 'poolAddress', + 'positionAddress', + 'walletAddress', + 'baseTokenAddress', + 'quoteTokenAddress', + 'signature', + 'txHash', + 'transactionHash', + 'quoteId', + 'tokenId', +]); + +export const parseWire = (body: string): any => revive(JSON.parse(body)); + +const revive = (value: any, key?: string): any => { + if (typeof value === 'string') { + return !IDENTIFIERS.has(key ?? '') && NUMERIC.test(value) ? Number(value) : value; + } + if (Array.isArray(value)) return value.map((entry) => revive(entry, key)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, entry]) => [k, revive(entry, k)])); + } + return value; +}; diff --git a/test/wallet/hardware-wallet.routes.test.ts b/test/wallet/hardware-wallet.routes.test.ts index 4480c082ea..5a1fa1d143 100644 --- a/test/wallet/hardware-wallet.routes.test.ts +++ b/test/wallet/hardware-wallet.routes.test.ts @@ -1,5 +1,5 @@ import sensible from '@fastify/sensible'; -import Fastify, { FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; jest.mock('../../src/services/hardware-wallet-service'); jest.mock('../../src/wallet/utils'); @@ -11,6 +11,7 @@ import { Solana } from '../../src/chains/solana/solana'; import { HardwareWalletService } from '../../src/services/hardware-wallet-service'; import { addHardwareWalletRoute } from '../../src/wallet/routes/addHardwareWallet'; import { getHardwareWallets, saveHardwareWallets, validateChainName } from '../../src/wallet/utils'; +import { fastifyWithTypeProvider } from '../utils/testUtils'; describe('Hardware Wallet Routes', () => { let app: FastifyInstance; @@ -19,7 +20,7 @@ describe('Hardware Wallet Routes', () => { beforeEach(async () => { jest.clearAllMocks(); - app = Fastify(); + app = fastifyWithTypeProvider(); await app.register(addHardwareWalletRoute); mockHardwareWalletService = { @@ -76,11 +77,12 @@ describe('Hardware Wallet Routes', () => { const response = await app.inject({ method: 'POST', url: '/add-hardware', + // accountIndex and name used to be sent here and silently dropped: the route + // declares neither and reads neither, so a caller naming their Ledger lost the + // name without being told. body: { chain: 'solana', address: mockAddress, - accountIndex: 0, - name: 'My Ledger', }, }); diff --git a/test/wallet/setDefault.routes.test.ts b/test/wallet/setDefault.routes.test.ts index d2ab3e8ed3..f337dad6a1 100644 --- a/test/wallet/setDefault.routes.test.ts +++ b/test/wallet/setDefault.routes.test.ts @@ -1,5 +1,5 @@ import sensible from '@fastify/sensible'; -import Fastify, { FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; jest.mock('../../src/wallet/utils'); jest.mock('../../src/config/utils'); @@ -14,6 +14,7 @@ import { Solana } from '../../src/chains/solana/solana'; import { updateDefaultWallet } from '../../src/config/utils'; import { setDefaultRoute } from '../../src/wallet/routes/setDefault'; import { validateChainName, getSafeWalletFilePath, isHardwareWallet } from '../../src/wallet/utils'; +import { fastifyWithTypeProvider } from '../utils/testUtils'; const SOLANA_ADDRESS = 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH'; @@ -23,7 +24,7 @@ describe('POST /setDefault', () => { beforeEach(async () => { jest.clearAllMocks(); - app = Fastify(); + app = fastifyWithTypeProvider(); await app.register(sensible); await app.register(setDefaultRoute);