From 6590e9f7e2ef1d1ef69791729fcd0146b7a318b8 Mon Sep 17 00:00:00 2001 From: Cooper-Kunz Date: Wed, 25 Feb 2026 03:28:38 -0500 Subject: [PATCH 1/4] finish renaming economics --- docs/errors.md | 2 +- src/modules/auctions/multicurve/mapper.ts | 24 +++++++++++------------ tests/unit/schema.test.ts | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index b45c6e2..a97b0c4 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -45,7 +45,7 @@ All errors are returned as: - `MIGRATION_MODE_UNSUPPORTED` - `GOVERNANCE_MODE_UNSUPPORTED` - `NUMERAIRE_REQUIRED` -- `INVALID_TOKENOMICS` +- `INVALID_ECONOMICS` - includes allocation errors such as: - `tokensForSale` below 20% market minimum when using split allocations - non-market allocation sums not matching `totalSupply - tokensForSale` diff --git a/src/modules/auctions/multicurve/mapper.ts b/src/modules/auctions/multicurve/mapper.ts index 356a85c..cb7648a 100644 --- a/src/modules/auctions/multicurve/mapper.ts +++ b/src/modules/auctions/multicurve/mapper.ts @@ -53,13 +53,13 @@ export const resolveSaleNumbers = ( : totalSupply; if (tokensForSale <= 0n) { - throw new AppError(422, 'INVALID_TOKENOMICS', 'economics.tokensForSale must be > 0'); + throw new AppError(422, 'INVALID_ECONOMICS', 'economics.tokensForSale must be > 0'); } if (tokensForSale > totalSupply) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', 'economics.tokensForSale cannot exceed economics.totalSupply', ); } @@ -70,7 +70,7 @@ export const resolveSaleNumbers = ( if (marketPercentWad < minMarketPercentWad) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', `economics.tokensForSale must be at least ${MIN_MARKET_SALE_PERCENT.toString()}% of totalSupply`, ); } @@ -81,7 +81,7 @@ export const resolveSaleNumbers = ( if (explicitAllocationTotal !== remainder) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', `${explicitAllocationsPath} must sum exactly to totalSupply - tokensForSale`, ); } @@ -110,7 +110,7 @@ const parseExplicitAllocations = (input: CreateLaunchRequestInput): ParsedExplic if (requested.length > MAX_ALLOCATION_RECIPIENTS) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', `${fieldPath} supports up to ${MAX_ALLOCATION_RECIPIENTS} unique addresses`, ); } @@ -121,7 +121,7 @@ const parseExplicitAllocations = (input: CreateLaunchRequestInput): ParsedExplic if (seen.has(normalized)) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', `${fieldPath} has duplicate address at index ${index}`, ); } @@ -150,7 +150,7 @@ export const resolveAllocationPlan = (args: { if (config) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', 'economics.allocations requires tokensForSale to be less than totalSupply', ); } @@ -169,7 +169,7 @@ export const resolveAllocationPlan = (args: { if (explicitAllocations.length > 0 && config?.recipientAddress) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', 'economics.allocations.recipientAddress cannot be used with explicit recipient splits', ); } @@ -185,7 +185,7 @@ export const resolveAllocationPlan = (args: { if (config?.durationSeconds !== undefined && config.durationSeconds !== 0) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', 'economics.allocations.durationSeconds must be 0 when mode is "unlock"', ); } @@ -195,7 +195,7 @@ export const resolveAllocationPlan = (args: { if (lockDurationSeconds <= 0) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', 'economics.allocations.durationSeconds must be > 0 for vest/vault modes', ); } @@ -204,7 +204,7 @@ export const resolveAllocationPlan = (args: { if (cliffDurationSeconds > lockDurationSeconds) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', 'economics.allocations.cliffDurationSeconds cannot exceed durationSeconds', ); } @@ -221,7 +221,7 @@ export const resolveAllocationPlan = (args: { if (totalExplicit !== allocationAmount) { throw new AppError( 422, - 'INVALID_TOKENOMICS', + 'INVALID_ECONOMICS', 'allocation amounts must equal totalSupply - tokensForSale', ); } diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts index 73949fd..a9450c2 100644 --- a/tests/unit/schema.test.ts +++ b/tests/unit/schema.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { createLaunchRequestSchema } from '../../src/modules/launches/schema'; describe('create launch schema', () => { - it('accepts valid payload with recipients alias', () => { + it('accepts valid payload with recipients', () => { const parsed = createLaunchRequestSchema.parse({ userAddress: '0x1111111111111111111111111111111111111111', integrationAddress: '0x2222222222222222222222222222222222222222', From 68826c4127d061fe11831ebe1f037579eb0e7898 Mon Sep 17 00:00:00 2001 From: Cooper-Kunz Date: Tue, 17 Mar 2026 02:05:58 -0400 Subject: [PATCH 2/4] update redis naming+docs for standalone mode --- .env.example | 4 +- AGENT_INTEGRATION.md | 31 +++++++---- README.md | 47 ++++++++++------ docs/api-reference.md | 4 +- docs/configuration.md | 57 ++++++++++++-------- docs/openapi.yaml | 8 +-- doppler.config.ts | 2 +- src/core/config.ts | 4 +- src/core/template-config.ts | 2 +- tests/integration/multichain-routing.test.ts | 2 +- tests/integration/test-server.ts | 2 +- tests/unit/config.test.ts | 2 +- tests/unit/pricing-resolution.test.ts | 2 +- 13 files changed, 102 insertions(+), 65 deletions(-) diff --git a/.env.example b/.env.example index e24af1f..92bc2bb 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ PRIVATE_KEY=0xyour_private_key # Optional core overrides (defaults come from doppler.config.ts) PORT=3000 LOG_LEVEL=info -DEPLOYMENT_MODE=local +DEPLOYMENT_MODE=standalone DEFAULT_CHAIN_ID=84532 RPC_URL= DEFAULT_NUMERAIRE_ADDRESS= @@ -16,7 +16,7 @@ CORS_ORIGINS= RATE_LIMIT_MAX=100 RATE_LIMIT_WINDOW_MS=60000 -# Optional Redis (required when DEPLOYMENT_MODE=shared) +# Optional Redis (recommended in standalone mode, required in shared mode) REDIS_URL= REDIS_KEY_PREFIX=doppler-api diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md index a93c2a6..f75f525 100644 --- a/AGENT_INTEGRATION.md +++ b/AGENT_INTEGRATION.md @@ -46,16 +46,6 @@ npm run test:live --verbose - Add `--verbose` to print per-launch parameter + onchain verification tables. - Live launch creation tests run sequentially to avoid nonce conflicts for one signer. -## 1c. Shared/prod mode requirements - -- Set `DEPLOYMENT_MODE=shared` (or run with `NODE_ENV=production` and no explicit deployment mode). -- Set `REDIS_URL` and `IDEMPOTENCY_BACKEND=redis`. -- In shared mode, create endpoints require `Idempotency-Key`. -- Rate-limit state is Redis-backed; `GET /health` is IP-bucketed (spoofed `x-api-key` does not bypass). -- Tx submission uses a Redis-backed distributed nonce lock so replicas can safely share one signer. -- Redis idempotency writes an `in_progress` marker before tx submit and fails closed with `409 IDEMPOTENCY_KEY_IN_DOUBT` if a prior attempt is left in doubt after restart/crash. -- Shared mode startup fails fast if Redis is unreachable. - ## 2. Required auth Include API key header on all endpoints except `GET /health`: @@ -87,7 +77,7 @@ Auction selection guidance: - Use `auction.type="dynamic"` for high value assets that need maximally capital-efficient price discovery. - Use `auction.type="static"` only for networks that do not support Uniswap V4. -Use `Idempotency-Key` on all create requests in shared/prod integrations (required by policy). +Use `Idempotency-Key` on all create requests in shared integrations (required by policy and recommended in standalone mode). If a retry returns `409 IDEMPOTENCY_KEY_IN_DOUBT`, poll status for the prior launch attempt before deciding to mint a new idempotency key. ## 4. Minimal request template @@ -178,6 +168,25 @@ Use this when you want intentional market-cap bands and allocation shares instea } ``` +## 5. Deployment modes and Redis + +- `standalone`: one API instance owns its own local state and does not need cross-instance coordination. +- `shared`: multiple API instances can serve the same workload safely by coordinating through Redis. + +- Standalone mode: + - Set `DEPLOYMENT_MODE=standalone`. + - Redis is optional. + - Good fit for one API instance, one signer, and durable local storage. + - Redis is recommended if you want stronger crash/restart recovery for create requests. +- Shared mode: + - Set `DEPLOYMENT_MODE=shared` (or run with `NODE_ENV=production` and no explicit deployment mode). + - Set `REDIS_URL` and `IDEMPOTENCY_BACKEND=redis`. + - In shared mode, create endpoints require `Idempotency-Key`. + - Rate-limit state is Redis-backed; `GET /health` is IP-bucketed (spoofed `x-api-key` does not bypass). + - Tx submission uses a Redis-backed distributed nonce lock so replicas can safely share one signer. + - Redis idempotency writes an `in_progress` marker before tx submit and fails closed with `409 IDEMPOTENCY_KEY_IN_DOUBT` if a prior attempt is left in doubt after restart/crash. + - Shared mode startup fails fast if Redis is unreachable. + ## 4c. Sale split template (20% sale / 80% non-market allocation) ```json diff --git a/README.md b/README.md index 322a9b9..5550acd 100644 --- a/README.md +++ b/README.md @@ -45,21 +45,6 @@ npm run dev - Required secrets remain in env: `API_KEY`, `PRIVATE_KEY` (and `REDIS_URL` when needed). - The template object is type-checked via `DopplerTemplateConfigV1`; config shape drift fails build/typecheck. -## Deployment modes and Redis - -- Local default (`DEPLOYMENT_MODE=local`): - - `IDEMPOTENCY_BACKEND=file` (default) - - no Redis required -- Shared/prod (`DEPLOYMENT_MODE=shared`, or `NODE_ENV=production` when `DEPLOYMENT_MODE` is unset): - - `REDIS_URL` is required - - `IDEMPOTENCY_BACKEND` must be `redis` - - create endpoints always require `Idempotency-Key` (`IDEMPOTENCY_REQUIRE_KEY=true` is enforced) - - rate-limit state is Redis-backed for cross-replica consistency - - nonce submission uses a Redis-backed distributed signer lock for cross-replica coordination - - Redis-backed idempotency writes an `in_progress` marker before tx submit to close crash/restart duplicate windows - - retries against a stuck `in_progress` marker fail closed with `409 IDEMPOTENCY_KEY_IN_DOUBT`; verify launch status before attempting a new key - - Redis in-flight lock uses a heartbeat; tune `IDEMPOTENCY_REDIS_LOCK_TTL_MS` to exceed max expected create duration - ## Current target feature set - Auction types: @@ -157,6 +142,38 @@ content-type: application/json } ``` +## Deployment modes and Redis + +This repo currently supports two runtime modes: + +- `standalone`: one API instance owns its own local state and does not need cross-instance coordination. +- `shared`: multiple API instances can serve the same workload safely by coordinating through Redis. + +- Single-instance / standalone (`DEPLOYMENT_MODE=standalone`) + - This is the default typed config mode and the simplest way to run the API. + - `IDEMPOTENCY_BACKEND=file` is the default. + - Redis is optional. + - Good fit for one API instance, one signer, and a durable local filesystem. + - Redis is still recommended if you want stronger idempotency recovery around crashes/restarts. +- Shared / multi-instance (`DEPLOYMENT_MODE=shared`) + - Intended for horizontally scaled or production-style shared deployments. + - `REDIS_URL` is required. + - `IDEMPOTENCY_BACKEND` must be `redis`. + - Create endpoints always require `Idempotency-Key` (`IDEMPOTENCY_REQUIRE_KEY=true` is enforced). + - Rate-limit state is Redis-backed for cross-replica consistency. + - Nonce submission uses a Redis-backed distributed signer lock for cross-replica coordination. + - Redis-backed idempotency writes an `in_progress` marker before tx submit to close crash/restart duplicate windows. + - Retries against a stuck `in_progress` marker fail closed with `409 IDEMPOTENCY_KEY_IN_DOUBT`; verify launch status before attempting a new key. + - Redis in-flight lock uses a heartbeat; tune `IDEMPOTENCY_REDIS_LOCK_TTL_MS` to exceed max expected create duration. + +`NODE_ENV=production` with no explicit `DEPLOYMENT_MODE` resolves to `shared`, so Redis becomes required in that case. + +### Redis guidance + +- Optional: single-instance / standalone deployments that use file-backed idempotency. +- Recommended: any deployment that wants stronger crash/restart recovery for create requests, even with one instance. +- Required: any shared deployment, multi-replica deployment, or any setup that explicitly sets `IDEMPOTENCY_BACKEND=redis`. + ## Curve configuration examples ### Multicurve explicit ranges (non-preset) diff --git a/docs/api-reference.md b/docs/api-reference.md index 256d6a4..91f78d1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -137,8 +137,8 @@ Generic launch creation endpoint (future-compatible). #### Idempotency header - Request header: `Idempotency-Key: ` - - optional in local mode - - required in shared/prod mode + - optional in standalone mode + - required in shared mode - same key + same request payload: returns original response and sets `x-idempotency-replayed: true` - same key + different payload: returns `409 IDEMPOTENCY_KEY_REUSE_MISMATCH` - if a prior create attempt crashed/restarted after tx submit and left the key `in_progress`, retries fail closed with `409 IDEMPOTENCY_KEY_IN_DOUBT` diff --git a/docs/configuration.md b/docs/configuration.md index c7a2f73..6d03750 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,7 +24,7 @@ Edit `doppler.config.ts` for: - `PORT` (default from `doppler.config.ts`) - `DEPLOYMENT_MODE` (default from `doppler.config.ts`) - - allowed: `local`, `shared` + - allowed: `standalone`, `shared` - if unset and `NODE_ENV=production`, deployment mode defaults to `shared` - `DEFAULT_CHAIN_ID` (must exist in `doppler.config.ts`) - `RPC_URL` @@ -41,11 +41,6 @@ Edit `doppler.config.ts` for: - `RATE_LIMIT_MAX` (default from `doppler.config.ts`) - `RATE_LIMIT_WINDOW_MS` (default from `doppler.config.ts`) -## Redis environment variables - -- `REDIS_URL` -- `REDIS_KEY_PREFIX` (default from `doppler.config.ts`) - ## Idempotency environment variables - `IDEMPOTENCY_ENABLED` (default from `doppler.config.ts`) @@ -60,23 +55,6 @@ Edit `doppler.config.ts` for: - `IDEMPOTENCY_REDIS_LOCK_REFRESH_MS` (default from `doppler.config.ts`) - heartbeat interval for refreshing the Redis in-flight lock - must be lower than `IDEMPOTENCY_REDIS_LOCK_TTL_MS` -- Redis-backed idempotency writes an `in_progress` marker before create submit. - If a process crashes/restarts before completion, retries with the same key fail closed with - `409 IDEMPOTENCY_KEY_IN_DOUBT` until operators verify prior attempt status. - -## Shared mode guardrails - -When `DEPLOYMENT_MODE=shared`: - -- `REDIS_URL` is required. -- `IDEMPOTENCY_ENABLED` must be `true`. -- `IDEMPOTENCY_BACKEND` must be `redis`. -- create endpoints require `Idempotency-Key`. -- rate-limiter state uses Redis for cross-replica consistency. -- tx nonce submission uses a Redis-backed distributed signer lock for cross-replica coordination. -- startup fails fast if Redis cannot be reached. - -Local mode remains file-backed by default and does not require Redis. ## Pricing environment variables @@ -126,3 +104,36 @@ chains: { - Dynamic launches require `migrationModes` to include `"uniswapV2"` and/or `"uniswapV4"`. - `uniswapV3` migration is not supported and returns `501 MIGRATION_NOT_IMPLEMENTED` if requested. - If you intentionally want the V3 static path on Base Sepolia for testing, include `"static"` in that chain's `auctionTypes` list. + +## Redis environment variables + +- `REDIS_URL` +- `REDIS_KEY_PREFIX` (default from `doppler.config.ts`) + +## Deployment mode guidance + +- `standalone`: one API instance owns its own local state and does not need cross-instance coordination. +- `shared`: multiple API instances can serve the same workload safely by coordinating through Redis. + +When `DEPLOYMENT_MODE=standalone`: + +- Redis is optional. +- File-backed idempotency is the default. +- Good fit for one API instance, one signer, and a durable local filesystem. +- Redis is recommended if you want stronger crash/restart recovery around create requests. + +When `DEPLOYMENT_MODE=shared`: + +- `REDIS_URL` is required. +- `IDEMPOTENCY_ENABLED` must be `true`. +- `IDEMPOTENCY_BACKEND` must be `redis`. +- create endpoints require `Idempotency-Key`. +- rate-limiter state uses Redis for cross-replica consistency. +- tx nonce submission uses a Redis-backed distributed signer lock for cross-replica coordination. +- startup fails fast if Redis cannot be reached. + +`NODE_ENV=production` with no explicit `DEPLOYMENT_MODE` resolves to `shared`. + +Redis-backed idempotency writes an `in_progress` marker before create submit. +If a process crashes/restarts before completion, retries with the same key fail closed with +`409 IDEMPOTENCY_KEY_IN_DOUBT` until operators verify prior attempt status. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1c8ebf3..9cdb89c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -33,7 +33,7 @@ paths: required: false schema: type: string - description: Reuse the same key for safe retries of identical create payloads (required in shared/prod mode). + description: Reuse the same key for safe retries of identical create payloads (required in shared mode, recommended in standalone mode). requestBody: required: true content: @@ -70,7 +70,7 @@ paths: required: false schema: type: string - description: Reuse the same key for safe retries of identical create payloads (required in shared/prod mode). + description: Reuse the same key for safe retries of identical create payloads (required in shared mode, recommended in standalone mode). requestBody: required: true content: @@ -107,7 +107,7 @@ paths: required: false schema: type: string - description: Reuse the same key for safe retries of identical create payloads (required in shared/prod mode). + description: Reuse the same key for safe retries of identical create payloads (required in shared mode, recommended in standalone mode). requestBody: required: true content: @@ -144,7 +144,7 @@ paths: required: false schema: type: string - description: Reuse the same key for safe retries of identical create payloads (required in shared/prod mode). + description: Reuse the same key for safe retries of identical create payloads (required in shared mode, recommended in standalone mode). requestBody: required: true content: diff --git a/doppler.config.ts b/doppler.config.ts index 608dba4..0ed1bcb 100644 --- a/doppler.config.ts +++ b/doppler.config.ts @@ -3,7 +3,7 @@ import type { DopplerTemplateConfigV1 } from './src/core/template-config'; export const dopplerTemplateConfig = { version: 1, port: 3000, - deploymentMode: 'local', + deploymentMode: 'standalone', defaultChainId: 84532, logLevel: 'info', readyRpcTimeoutMs: 2000, diff --git a/src/core/config.ts b/src/core/config.ts index 9ba51b6..6853a2d 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -92,11 +92,11 @@ const parseDeploymentMode = (fallback: DeploymentMode): DeploymentMode => { return fallback; } - if (raw === 'local' || raw === 'shared') { + if (raw === 'standalone' || raw === 'shared') { return raw; } - throw new AppError(500, 'INVALID_ENV', 'DEPLOYMENT_MODE must be "local" or "shared"'); + throw new AppError(500, 'INVALID_ENV', 'DEPLOYMENT_MODE must be "standalone" or "shared"'); }; const parseIdempotencyBackend = (fallback: IdempotencyBackend): IdempotencyBackend => { diff --git a/src/core/template-config.ts b/src/core/template-config.ts index 9888b71..ae5e7f0 100644 --- a/src/core/template-config.ts +++ b/src/core/template-config.ts @@ -1,6 +1,6 @@ import type { AuctionType, GovernanceMode, MigrationType } from './types'; -export type DeploymentMode = 'local' | 'shared'; +export type DeploymentMode = 'standalone' | 'shared'; export type IdempotencyBackend = 'file' | 'redis'; export type PriceProvider = 'coingecko' | 'none'; diff --git a/tests/integration/multichain-routing.test.ts b/tests/integration/multichain-routing.test.ts index 84778cd..13337a0 100644 --- a/tests/integration/multichain-routing.test.ts +++ b/tests/integration/multichain-routing.test.ts @@ -17,7 +17,7 @@ describe('GET /v1/capabilities', () => { it('returns per-chain capability matrix', async () => { const config: AppConfig = { port: 3000, - deploymentMode: 'local', + deploymentMode: 'standalone', apiKey: 'test-key', apiKeys: ['test-key'], defaultChainId: 84532, diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 16a329a..daf3632 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -9,7 +9,7 @@ interface BuildTestServerOptions { export const buildTestServer = async (options: BuildTestServerOptions = {}) => { const config: AppConfig = { port: 3000, - deploymentMode: 'local', + deploymentMode: 'standalone', apiKey: 'test-key', apiKeys: ['test-key'], defaultChainId: 84532, diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 5d25105..c9b14db 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -54,7 +54,7 @@ describe('shared-environment config guardrails', () => { expect(config.defaultChainId).toBe(84532); expect(config.chains[84532]?.rpcUrl).toBe('https://base-sepolia-rpc.publicnode.com'); - expect(config.deploymentMode).toBe('local'); + expect(config.deploymentMode).toBe('standalone'); expect(config.idempotency.backend).toBe('file'); expect(config.idempotency.requireKey).toBe(false); expect(config.redis.url).toBeUndefined(); diff --git a/tests/unit/pricing-resolution.test.ts b/tests/unit/pricing-resolution.test.ts index 0348527..a1914bc 100644 --- a/tests/unit/pricing-resolution.test.ts +++ b/tests/unit/pricing-resolution.test.ts @@ -5,7 +5,7 @@ import type { AppConfig } from '../../src/core/config'; const baseConfig: AppConfig = { port: 3000, - deploymentMode: 'local', + deploymentMode: 'standalone', apiKey: 'test', apiKeys: ['test'], defaultChainId: 84532, From 46b08a5aaeded35d222e8efc4322b364acfbda4f Mon Sep 17 00:00:00 2001 From: Cooper-Kunz Date: Thu, 16 Apr 2026 23:16:28 -0400 Subject: [PATCH 3/4] feature: add solana devnet support --- .env.example | 22 + .gitignore | 3 +- AGENT_INTEGRATION.md | 115 +- README.md | 120 +- docs/api-reference.md | 270 ++-- docs/configuration.md | 179 ++- docs/errors.md | 69 +- docs/openapi.yaml | 332 ++++- package-lock.json | 1122 ++++++++++++++++- package.json | 7 +- src/app/routes/capabilities.get.ts | 30 +- src/app/routes/launches.post.ts | 26 +- src/app/routes/ready.get.ts | 11 +- src/app/routes/solana-launches.post.ts | 27 + src/app/server.ts | 13 + src/core/config.ts | 169 ++- src/core/types.ts | 42 + src/infra/chain/receipt-decoder.ts | 2 +- src/infra/chain/registry.ts | 2 +- src/infra/doppler/sdk-client.ts | 2 +- src/infra/idempotency/store.ts | 184 ++- src/modules/auctions/dynamic/service.ts | 2 +- src/modules/auctions/multicurve/mapper.ts | 2 +- src/modules/auctions/multicurve/service.ts | 2 +- .../auctions/multicurve/tick-spacing.ts | 2 +- src/modules/auctions/static/service.ts | 2 +- src/modules/launches/service.ts | 26 +- src/modules/launches/solana.ts | 804 ++++++++++++ src/modules/pricing/provider.ts | 1 + src/modules/pricing/providers/coingecko.ts | 46 +- src/modules/pricing/service.ts | 22 + src/modules/status/service.ts | 2 +- tests/integration/health-ready.test.ts | 42 + tests/integration/multichain-routing.test.ts | 162 +++ tests/integration/server-startup.test.ts | 16 + tests/integration/solana-launches.test.ts | 496 ++++++++ tests/integration/test-server.ts | 190 ++- tests/live/create-and-verify.test.ts | 65 +- tests/live/helpers/live-support.ts | 16 +- tests/live/readiness-check.ts | 109 +- tests/live/scenarios/multicurve.live.ts | 2 +- tests/live/scenarios/solana.live.ts | 569 +++++++++ tests/unit/config.test.ts | 58 + tests/unit/fee-beneficiaries.test.ts | 2 +- tests/unit/idempotency-store.test.ts | 93 +- tests/unit/live-readiness-check.test.ts | 51 + tests/unit/pricing-resolution.test.ts | 10 + tests/unit/solana.test.ts | 295 +++++ 48 files changed, 5473 insertions(+), 361 deletions(-) create mode 100644 src/app/routes/solana-launches.post.ts create mode 100644 src/modules/launches/solana.ts create mode 100644 tests/integration/solana-launches.test.ts create mode 100644 tests/live/scenarios/solana.live.ts create mode 100644 tests/unit/solana.test.ts diff --git a/.env.example b/.env.example index 92bc2bb..34df743 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,28 @@ PRICE_CACHE_TTL_MS=15000 PRICE_API_KEY= PRICE_COINGECKO_ASSET_ID=ethereum +# Optional Solana +SOLANA_ENABLED=false +SOLANA_DEFAULT_NETWORK=solanaDevnet +SOLANA_DEVNET_RPC_URL=https://api.devnet.solana.com +SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com +SOLANA_MAINNET_BETA_RPC_URL= +SOLANA_MAINNET_BETA_WS_URL= +SOLANA_KEYPAIR= +SOLANA_CONFIRM_TIMEOUT_MS=60000 +SOLANA_DEVNET_USE_ALT=true +SOLANA_DEVNET_ALT_ADDRESS= +SOLANA_PRICE_MODE=required +SOLANA_FIXED_NUMERAIRE_PRICE_USD= +SOLANA_COINGECKO_ASSET_ID=solana + # Optional live tests LIVE_TEST_ENABLE=false +LIVE_TEST_VERBOSE=false LIVE_NUMERAIRE_PRICE_USD=3000 +LIVE_TEST_MIN_BALANCE_ETH= +LIVE_TEST_ESTIMATED_TX_COST_ETH= +LIVE_TEST_ESTIMATED_OVERHEAD_ETH= +LIVE_TEST_MIN_BALANCE_SOL= +LIVE_TEST_ESTIMATED_TX_COST_SOL= +LIVE_TEST_ESTIMATED_OVERHEAD_SOL= diff --git a/.gitignore b/.gitignore index 341cd15..b3c4f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ .DS_Store node_modules .env +.data/ dist coverage .test-results -.eslintcache \ No newline at end of file +.eslintcache diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md index f75f525..6121911 100644 --- a/AGENT_INTEGRATION.md +++ b/AGENT_INTEGRATION.md @@ -39,12 +39,19 @@ npm run test:live:multicurve npm run test:live:multicurve:defaults npm run test:live:fees npm run test:live:governance -npm run test:live --verbose +npm run test:live:solana +npm run test:live:solana:devnet +npm run test:live:solana:defaults +npm run test:live:solana:random +npm run test:live:solana:failing +LIVE_TEST_VERBOSE=true npm run test:live ``` - Live tests are concise by default and print a launch summary table. -- Add `--verbose` to print per-launch parameter + onchain verification tables. +- Set `LIVE_TEST_VERBOSE=true` to print per-launch parameter + onchain verification tables. - Live launch creation tests run sequentially to avoid nonce conflicts for one signer. +- `test:live` is the EVM baseline matrix; use `test:live:solana` or `test:live:solana:devnet` for the Solana devnet create matrix. +- Solana live tests require `SOLANA_ENABLED=true`, a funded `SOLANA_KEYPAIR`, reachable `SOLANA_DEVNET_RPC_URL` / `SOLANA_DEVNET_WS_URL`, and enough SOL for launch account creation. Use `LIVE_TEST_MIN_BALANCE_SOL`, `LIVE_TEST_ESTIMATED_TX_COST_SOL`, and `LIVE_TEST_ESTIMATED_OVERHEAD_SOL` to tune the readiness gate. ## 2. Required auth @@ -62,6 +69,8 @@ Include API key header on all endpoints except `GET /health`: ## 3. One launch flow +EVM flow: + 1. Call `POST /v1/launches`. - Optional aliases: - `POST /v1/launches/multicurve` (forces `auction.type="multicurve"`) @@ -71,6 +80,13 @@ Include API key header on all endpoints except `GET /health`: 3. Poll `GET /v1/launches/:launchId` every 3-5 seconds. 4. Stop when status is `confirmed` or `reverted`. +Solana flow: + +1. Call `POST /v1/solana/launches` or `POST /v1/launches` with `network: "solanaDevnet" | "solanaMainnetBeta"`. +2. Save `launchId`, `signature`, and `explorerUrl`. +3. Do not poll `GET /v1/launches/:launchId`; Solana is create-only in this iteration. +4. If the API returns `409 SOLANA_LAUNCH_IN_DOUBT`, use the returned `signature` and `explorerUrl` to reconcile the prior attempt before creating a new request. + Auction selection guidance: - Default to `auction.type="multicurve"` whenever the target chain supports Uniswap V4. @@ -79,6 +95,7 @@ Auction selection guidance: Use `Idempotency-Key` on all create requests in shared integrations (required by policy and recommended in standalone mode). If a retry returns `409 IDEMPOTENCY_KEY_IN_DOUBT`, poll status for the prior launch attempt before deciding to mint a new idempotency key. +If a Solana retry returns `409 SOLANA_LAUNCH_IN_DOUBT`, fail closed and reconcile by signature instead of retrying blindly. ## 4. Minimal request template @@ -114,6 +131,67 @@ If a retry returns `409 IDEMPOTENCY_KEY_IN_DOUBT`, poll status for the prior lau } ``` +## 4a. Solana minimal request template + +Use the dedicated route with short aliases: + +```json +{ + "network": "devnet", + "tokenMetadata": { + "name": "My Solana Token", + "symbol": "MSOL", + "tokenURI": "ipfs://metadata" + }, + "economics": { + "totalSupply": "1000000000" + }, + "pricing": { + "numerairePriceUsd": 150 + }, + "governance": false, + "migration": { + "type": "noOp" + }, + "auction": { + "type": "xyk", + "curveConfig": { + "type": "range", + "marketCapStartUsd": 100, + "marketCapEndUsd": 1000 + } + } +} +``` + +Use the generic route only with canonical prefixed networks: + +```json +{ + "network": "solanaDevnet", + "tokenMetadata": { + "name": "My Solana Token", + "symbol": "MSOL", + "tokenURI": "ipfs://metadata" + }, + "economics": { + "totalSupply": "1000000000" + }, + "governance": false, + "migration": { + "type": "noOp" + }, + "auction": { + "type": "xyk", + "curveConfig": { + "type": "range", + "marketCapStartUsd": 100, + "marketCapEndUsd": 1000 + } + } +} +``` + ## 4b. Custom curve (ranges) template Use this when you want intentional market-cap bands and allocation shares instead of preset tiers. @@ -382,10 +460,17 @@ Custom-curve rules agents should enforce before submit: ## 5. What to expect in create response -- `launchId`: stable tracking key (`:`) -- `txHash`: onchain tx hash -- `predicted.tokenAddress` and `predicted.poolId`: simulation outputs -- `effectiveConfig`: defaults actually used by API +- EVM: + - `launchId`: stable tracking key (`:`) + - `txHash`: onchain tx hash + - `predicted.tokenAddress` and `predicted.poolId`: simulation outputs + - `effectiveConfig`: defaults actually used by API +- Solana: + - `launchId`: base58 launch PDA + - `signature`: submitted transaction signature + - `explorerUrl`: direct explorer link + - `predicted`: SDK-derived token / authority / vault addresses + - `effectiveConfig`: resolved WSOL price and derived XYK reserves ## 6. Status handling rules @@ -393,9 +478,17 @@ Custom-curve rules agents should enforce before submit: - `confirmed`: use `result.tokenAddress` and `result.poolId`. - `reverted`: treat as failed launch and surface `error.code/message`. - `not_found`: retry briefly, then fail. +- Solana launches do not have a status route in this iteration. ## 7. Important defaults +- Solana rules: + - use `POST /v1/solana/launches` for short `network` aliases (`devnet`, `mainnet-beta`) + - use `POST /v1/launches` for Solana only when `network` is `solanaDevnet` or `solanaMainnetBeta` + - only `solanaDevnet` is executable; `solanaMainnetBeta` returns `501 SOLANA_NETWORK_UNSUPPORTED` + - only WSOL is supported as numeraire + - Solana price resolution precedence is request override, fixed env price, then CoinGecko + - unsupported EVM-shaped fields are rejected instead of ignored - `economics.tokensForSale` defaults to `totalSupply`. - if `tokensForSale < totalSupply`, market sale must be at least 20% of total supply. - Multicurve initializer defaults to `standard` (implemented as scheduled with `startTime=0`). @@ -461,6 +554,16 @@ curl -X POST http://localhost:3000/v1/launches \ -d @launch.json ``` +Solana create: + +```bash +curl -X POST http://localhost:3000/v1/solana/launches \ + -H 'content-type: application/json' \ + -H "x-api-key: $API_KEY" \ + -H "Idempotency-Key: solana-launch-$(date +%s)" \ + -d @solana-launch.json +``` + Status: ```bash diff --git a/README.md b/README.md index 5550acd..1f02862 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This project is in active development & not ready for production use. ## Endpoints - `POST /v1/launches` +- `POST /v1/solana/launches` - `POST /v1/launches/multicurve` (alias) - `POST /v1/launches/static` (alias) - `POST /v1/launches/dynamic` (alias) @@ -60,6 +61,13 @@ npm run dev - `noOp` for multicurve/static - `uniswapV2` and `uniswapV4` for dynamic - `uniswapV3` is not supported and returns `501 MIGRATION_NOT_IMPLEMENTED` +- Solana: + - create via `POST /v1/solana/launches` + - `POST /v1/launches` also accepts Solana when `network` is `solanaDevnet` or `solanaMainnetBeta` + - create-only in this iteration + - only `solanaDevnet` is executable + - only WSOL is supported as numeraire + - strict request shape; unsupported EVM-only fields are rejected - Governance: `enabled=false` is the active profile, eg. `noOp` - Token allocation profile: - Default: 100% of `totalSupply` is allocated to the multicurve market. @@ -75,10 +83,13 @@ npm run dev ## Launch ID format -`:` +- EVM: `:` +- Solana: base58 launch PDA -Example: -`84532:0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` +Examples: + +- `84532:0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` +- `8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP` ## Create launch example @@ -142,6 +153,49 @@ content-type: application/json } ``` +## Solana create example + +### Request + +```http +POST /v1/solana/launches +x-api-key: +Idempotency-Key: +content-type: application/json +``` + +```json +{ + "network": "devnet", + "tokenMetadata": { + "name": "My Solana Token", + "symbol": "MSOL", + "tokenURI": "ipfs://my-solana-token" + }, + "economics": { + "totalSupply": "1000000000" + }, + "pricing": { + "numerairePriceUsd": 150 + }, + "governance": false, + "migration": { + "type": "noOp" + }, + "auction": { + "type": "xyk", + "curveConfig": { + "type": "range", + "marketCapStartUsd": 100, + "marketCapEndUsd": 1000 + }, + "curveFeeBps": 25, + "allowBuy": true, + "allowSell": true + } +} +``` + ## Deployment modes and Redis This repo currently supports two runtime modes: @@ -290,6 +344,36 @@ Dynamic is intended for assets with well-known value that benefit from maximally } ``` +### Solana success response (`200`) + +```json +{ + "launchId": "8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP", + "network": "solanaDevnet", + "signature": "5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J", + "explorerUrl": "https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet", + "predicted": { + "tokenAddress": "6QWeT6FpJrm8AF1btu6WH2k2Xhq6t5vbheKVfQavmeoZ", + "launchAuthorityAddress": "E7Ud4m8S7fC2YdUQdL7p9V2sRrMfQjQ9fA5spuR4T9gQ", + "baseVaultAddress": "9xQeWvG816bUx9EPjHmaT23yvVMHh2eHq9cYqB9Yg6xT", + "quoteVaultAddress": "J1veWvV6BF8L7rN8D66zCFAaj6MqFmoVoeAQMtkP8dwF" + }, + "effectiveConfig": { + "tokensForSale": "1000000000", + "allocationAmount": "0", + "allocationLockMode": "none", + "numeraireAddress": "So11111111111111111111111111111111111111112", + "numerairePriceUsd": 150, + "curveVirtualBase": "1000000000", + "curveVirtualQuote": "100000000", + "curveFeeBps": 25, + "allowBuy": true, + "allowSell": true, + "tokenDecimals": 9 + } +} +``` + ## Status examples ### Pending (`200`) @@ -358,14 +442,23 @@ Dynamic is intended for assets with well-known value that benefit from maximally "governanceModes": ["noOp", "default"], "governanceEnabled": true } - ] + ], + "solana": { + "enabled": true, + "supportedNetworks": ["solanaDevnet"], + "unsupportedNetworks": ["solanaMainnetBeta"], + "dedicatedRouteInputAliases": ["devnet", "mainnet-beta"], + "creationOnly": true, + "numeraireAddress": "So11111111111111111111111111111111111111112", + "priceResolutionModes": ["request", "fixed", "coingecko"] + } } ``` ## Health and readiness - `GET /health`: process liveness -- `GET /ready`: dependency readiness (chain RPC checks, requires `x-api-key`) +- `GET /ready`: dependency readiness (EVM chain RPC checks plus Solana readiness, requires `x-api-key`) - `GET /metrics`: service metrics snapshot (requires `x-api-key`) - degraded readiness checks return a generic error string (`"dependency unavailable"`) to avoid leaking upstream internals @@ -377,6 +470,12 @@ Example `GET /health`: ## Validation and defaults +- Solana create-only rules: + - use `POST /v1/solana/launches` or `POST /v1/launches` with `network: "solanaDevnet" | "solanaMainnetBeta"` + - short Solana aliases are accepted only on the dedicated route + - `launchId` is a launch PDA and no Solana `statusUrl` is returned + - only WSOL is supported as numeraire + - Solana rejects unsupported EVM-only fields instead of ignoring them - `economics.tokensForSale` is optional: - if omitted, `tokensForSale = totalSupply` (100% sold to market). - if provided, it must be `> 0` and `<= totalSupply`. @@ -455,9 +554,16 @@ npm run test:live:multicurve npm run test:live:multicurve:defaults npm run test:live:fees npm run test:live:governance -npm run test:live --verbose +npm run test:live:solana +npm run test:live:solana:devnet +npm run test:live:solana:defaults +npm run test:live:solana:random +npm run test:live:solana:failing +LIVE_TEST_VERBOSE=true npm run test:live ``` `test:live` performs real on-chain creation and verification when `LIVE_TEST_ENABLE=true` and funded credentials are configured. -By default, live output is concise (launch summary table). Use `--verbose` for full per-launch parameter and verification tables. +By default, live output is concise (launch summary table). Set `LIVE_TEST_VERBOSE=true` for full per-launch parameter and verification tables. Live launch tests run sequentially to avoid nonce conflicts from a single funded signer. +`test:live` remains the EVM baseline matrix; use `test:live:solana` or `test:live:solana:devnet` for the Solana devnet matrix. +Solana live tests require `SOLANA_ENABLED=true`, a funded `SOLANA_KEYPAIR`, reachable `SOLANA_DEVNET_RPC_URL` / `SOLANA_DEVNET_WS_URL`, and enough SOL for account creation; override the readiness estimate with `LIVE_TEST_MIN_BALANCE_SOL`, `LIVE_TEST_ESTIMATED_TX_COST_SOL`, and `LIVE_TEST_ESTIMATED_OVERHEAD_SOL` when needed. diff --git a/docs/api-reference.md b/docs/api-reference.md index 91f78d1..5650d2d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,6 +6,7 @@ Base URL (local): `http://localhost:3000` - Required on: - `POST /v1/launches` + - `POST /v1/solana/launches` - `POST /v1/launches/multicurve` - `POST /v1/launches/static` - `POST /v1/launches/dynamic` @@ -22,118 +23,85 @@ Base URL (local): `http://localhost:3000` - Error envelope shape: `{ error: { code, message, details? } }` - Rate limiting returns `429 RATE_LIMITED`. -- `GET /health` is rate-limited by client IP (spoofed `x-api-key` values are ignored for bucketing). -- For all `5xx` responses, `message` is intentionally generic (`"Internal server error"`); - use server logs plus `x-request-id` for diagnostics. +- `GET /health` is rate-limited by client IP. +- For all `5xx` responses, `message` is intentionally generic (`"Internal server error"`). ## Implemented endpoints ### `POST /v1/launches` -Generic launch creation endpoint (future-compatible). +Generic create endpoint. -- v1 implementation supports: - - `auction.type = "multicurve"` (preferred on V4-capable networks) - - `auction.type = "dynamic"` (for high value assets requiring maximally capital-efficient price discovery) - - `auction.type = "static"` (Uniswap V3 static launch; fallback for networks without Uniswap V4 support) - - `migration.type = "noOp"` for multicurve/static - - `migration.type = "uniswapV2"` and `migration.type = "uniswapV4"` for dynamic - - `migration.type = "uniswapV3"` is explicitly unsupported (returns `501 MIGRATION_NOT_IMPLEMENTED`) - - `governance: false` (or omitted) for no governance - - `governance: true` (or `{ enabled: true }`) for default token-holder governance (OpenZeppelin Governor) +- EVM requests continue to use the existing EVM schema. +- Solana requests are dispatched only when `network` is one of: + - `solanaDevnet` + - `solanaMainnetBeta` +- Short Solana aliases (`devnet`, `mainnet-beta`) are rejected on this route. -#### Request body +#### EVM request shape -- `chainId?: number` (defaults to configured chain) +- `chainId?: number` - `userAddress: 0x...` - `integrationAddress?: 0x...` - `tokenMetadata: { name, symbol, tokenURI }` - `economics: { totalSupply, tokensForSale?, allocations? }` - - `tokensForSale` defaults to `totalSupply` when omitted. - - when `tokensForSale < totalSupply`, market allocation must be at least `20%` of `totalSupply`. - - `allocations` is optional metadata for lock behavior when `tokensForSale < totalSupply`: - - `recipientAddress?` (defaults to `userAddress`) - - `recipients?: [{ address, amount }]` - - optional explicit recipient split for non-market allocation remainder - - supports up to `10` unique addresses, no duplicates - - must sum exactly to `totalSupply - tokensForSale` - - if provided and `tokensForSale` is omitted, API derives `tokensForSale` from: - `totalSupply - sum(recipients[].amount)` - - `mode?` (`vest` | `unlock` | `vault`, default `vest`) - - `durationSeconds?` (default `7776000` for `vest`/`vault`) - - `cliffDurationSeconds?` (must be `<= durationSeconds`) - `pairing?: { numeraireAddress? }` - `pricing?: { numerairePriceUsd? }` - `feeBeneficiaries?: [{ address, sharesWad }]` - - supports up to `10` unique addresses - - when protocol owner is included, shares must sum to `1e18` and protocol owner must have at least `5%` (`WAD / 20`) - - when protocol owner is omitted, request shares must sum to `0.95e18` and API appends protocol owner at `0.05e18` - `governance?: boolean | { enabled, mode? }` - - `true` or `{ enabled: true }` => default token-holder governance (OpenZeppelin Governor) - - `false` or omitted => no governance - - when `mode` is provided it must match the binary value (`default` for enabled, `noOp` for disabled) - - default governance is provisioned via the protocol governance factory. -- `migration:` - - `{ type: "noOp" | "uniswapV2" | "uniswapV3" }` - - `{ type: "uniswapV4", fee, tickSpacing }` (required for dynamic V4 migration) +- `migration: { type: "noOp" | "uniswapV2" | "uniswapV3" } | { type: "uniswapV4", fee, tickSpacing }` +- `auction:` one of: + - `multicurve` + - `static` + - `dynamic` + +#### Solana request shape on the generic route + +- `network: "solanaDevnet" | "solanaMainnetBeta"` +- `tokenMetadata: { name, symbol, tokenURI }` +- `economics: { totalSupply }` +- `pairing?: { numeraireAddress? }` +- `pricing?: { numerairePriceUsd? }` +- `governance?: false` +- `migration?: { type: "noOp" }` - `auction:` - - `type: "multicurve"`: - - `curveConfig.type = "preset"`: - - `presets?: ("low"|"medium"|"high")[]` - - `fee?: number` - - `tickSpacing?: number` - - `curveConfig.type = "ranges"`: - - `fee?: number` - - `tickSpacing?: number` - - `curves: [{ marketCapStartUsd, marketCapEndUsd, numPositions, sharesWad }]` - - `marketCapEndUsd` accepts positive number or `"max"` in API payloads - - example explicit first band: `marketCapStartUsd: 100` - - custom multicurve fees are supported via `curveConfig.fee` - - `initializer?`: - - `{ type: "standard" }` (implemented via scheduled initializer at startTime `0`) - - `{ type: "scheduled", startTime }` - - `{ type: "decay", startFee, durationSeconds, startTime? }` - - `{ type: "rehype", config: { hookAddress, buybackDestination, customFee, assetBuybackPercentWad, numeraireBuybackPercentWad, beneficiaryPercentWad, lpPercentWad, graduationCalldata?, graduationMarketCap?, numerairePrice?, farTick? } }` - - `type: "static"`: - - `curveConfig.type = "preset"`: - - `preset: ("low"|"medium"|"high")` - - `fee?: number` - - `numPositions?: number` - - `maxShareToBeSoldWad?: string` - - `curveConfig.type = "range"`: - - `marketCapStartUsd: number` - - `marketCapEndUsd: number` - - `fee?: number` - - `numPositions?: number` - - `maxShareToBeSoldWad?: string` - - example range start: `marketCapStartUsd: 100` - - custom static fee input is supported via `curveConfig.fee` (must still be a valid Uniswap V3 fee tier onchain) - - static launches are configured with lockable beneficiaries (request values or default split) - - static is intended for chains that do not support Uniswap V4 multicurve paths - - `type: "dynamic"`: - - intended for assets with well-known value that need maximally capital-efficient price discovery - - `curveConfig.type = "range"`: - - `marketCapStartUsd: number` (starting market cap in USD) - - `marketCapMinUsd: number` (minimum market cap floor in USD, must be lower than start) - - `minProceeds: string` (decimal string in numeraire units, e.g. `"0.01"`) - - `maxProceeds: string` (decimal string in numeraire units, e.g. `"0.1"`) - - optional: `durationSeconds`, `epochLengthSeconds`, `fee`, `tickSpacing`, `gamma`, `numPdSlugs` - - custom dynamic fees are supported via `curveConfig.fee` - - migration policy: - - dynamic requires `migration.type = "uniswapV2"` or `migration.type = "uniswapV4"` - - `migration.type = "uniswapV4"` requires `migration.fee` and `migration.tickSpacing` - - `migration.type = "uniswapV4"` derives streamable fee beneficiaries from `feeBeneficiaries` (or default 95/5) - - `migration.type = "uniswapV3"` is reserved and currently returns `501 MIGRATION_NOT_IMPLEMENTED` - - exit/migration behavior: - - migrate immediately when `maxProceeds` is reached - - otherwise migrate at auction maturity only if `minProceeds` is reached + - `type: "xyk"` + - `curveConfig: { type: "range", marketCapStartUsd, marketCapEndUsd }` + - `curveFeeBps?: number` + - `allowBuy?: boolean` + - `allowSell?: boolean` + +#### Solana request constraints + +- Solana support is create-only in this iteration. +- `solanaMainnetBeta` is scaffolded but returns `501 SOLANA_NETWORK_UNSUPPORTED`. +- WSOL is the only supported numeraire. +- Unsupported fields are rejected instead of ignored, including: + - `economics.tokensForSale` + - allocations / vesting fields + - fee beneficiaries + - prediction-market fields + - `governance !== false` + - `migration.type !== "noOp"` + - non-`xyk` auction payloads #### Response `200` +EVM response: + - `launchId`, `chainId`, `txHash`, `statusUrl` - `predicted: { tokenAddress, poolId, gasEstimate? }` - `effectiveConfig: { tokensForSale, allocationAmount, allocationRecipient, allocationRecipients?, allocationLockMode, allocationLockDurationSeconds, numeraireAddress, numerairePriceUsd, feeBeneficiariesSource, initializer? }` +Solana response: + +- `launchId` +- `network` +- `signature` +- `explorerUrl` +- `predicted: { tokenAddress, launchAuthorityAddress, baseVaultAddress, quoteVaultAddress }` +- `effectiveConfig: { tokensForSale, allocationAmount, allocationLockMode, numeraireAddress, numerairePriceUsd, curveVirtualBase, curveVirtualQuote, curveFeeBps, allowBuy, allowSell, tokenDecimals }` + #### Idempotency header - Request header: `Idempotency-Key: ` @@ -141,50 +109,92 @@ Generic launch creation endpoint (future-compatible). - required in shared mode - same key + same request payload: returns original response and sets `x-idempotency-replayed: true` - same key + different payload: returns `409 IDEMPOTENCY_KEY_REUSE_MISMATCH` -- if a prior create attempt crashed/restarted after tx submit and left the key `in_progress`, retries fail closed with `409 IDEMPOTENCY_KEY_IN_DOUBT` -- when `IDEMPOTENCY_REQUIRE_KEY=true` (always true in shared mode), create requests without header return `422 IDEMPOTENCY_KEY_REQUIRED` +- EVM crash-window retries can return `409 IDEMPOTENCY_KEY_IN_DOUBT` +- Solana ambiguous confirmation retries can return `409 SOLANA_LAUNCH_IN_DOUBT` #### Error responses - `401 UNAUTHORIZED` - `429 RATE_LIMITED` -- `422 INVALID_REQUEST` (schema validation) and domain-specific validation errors -- `409 IDEMPOTENCY_KEY_IN_DOUBT` when a previous same-key create attempt may have submitted but did not finalize idempotency state -- `501 MIGRATION_NOT_IMPLEMENTED` for unsupported migration modes (for example `uniswapV3`) -- `500 INTERNAL_ERROR` (message is generic) +- `422 INVALID_REQUEST` plus domain-specific `422` Solana or EVM validation failures +- `409 IDEMPOTENCY_KEY_IN_DOUBT` +- `409 SOLANA_LAUNCH_IN_DOUBT` +- `501 MIGRATION_NOT_IMPLEMENTED` +- `501 SOLANA_NETWORK_UNSUPPORTED` +- `502 SOLANA_SUBMISSION_FAILED` +- `503 SOLANA_NOT_READY` +- `500 INTERNAL_ERROR` + +--- + +### `POST /v1/solana/launches` + +Dedicated Solana create endpoint. + +#### Request body + +- `network?: "devnet" | "mainnet-beta"` + - omitted uses `SOLANA_DEFAULT_NETWORK` + - `devnet` normalizes to `solanaDevnet` + - `mainnet-beta` normalizes to `solanaMainnetBeta` +- `tokenMetadata: { name, symbol, tokenURI }` +- `economics: { totalSupply }` +- `pairing?: { numeraireAddress? }` +- `pricing?: { numerairePriceUsd? }` +- `governance?: false` +- `migration?: { type: "noOp" }` +- `auction: { type: "xyk", curveConfig: { type: "range", marketCapStartUsd, marketCapEndUsd }, curveFeeBps?, allowBuy?, allowSell? }` + +#### Response `200` + +- `launchId` is the base58 launch PDA +- no `statusUrl` is returned +- response shape matches the Solana response described on `POST /v1/launches` + +#### Create-time Solana validation + +- `solanaMainnetBeta` -> `501 SOLANA_NETWORK_UNSUPPORTED` +- non-WSOL numeraire -> `422 SOLANA_NUMERAIRE_UNSUPPORTED` +- missing price after request/env/provider resolution -> `422 SOLANA_NUMERAIRE_PRICE_REQUIRED` +- invalid metadata -> `422 SOLANA_INVALID_METADATA` +- invalid market-cap range or fee input -> `422 SOLANA_INVALID_CURVE` +- readiness failure -> `503 SOLANA_NOT_READY` +- simulation failure -> `422 SOLANA_SIMULATION_FAILED` +- submission failure -> `502 SOLANA_SUBMISSION_FAILED` +- ambiguous confirmation -> `409 SOLANA_LAUNCH_IN_DOUBT` --- ### `POST /v1/launches/multicurve` -Convenience alias for multicurve launches. +Convenience alias for EVM multicurve launches. - Internally forwards to `POST /v1/launches` and forces `auction.type = "multicurve"`. -- Same auth, request shape, response, and error model as `POST /v1/launches`. +- Solana is not supported on this alias route. --- ### `POST /v1/launches/static` -Convenience alias for static launches. +Convenience alias for EVM static launches. - Internally forwards to `POST /v1/launches` and forces `auction.type = "static"`. -- Same auth, request shape, response, and error model as `POST /v1/launches`. +- Solana is not supported on this alias route. --- ### `POST /v1/launches/dynamic` -Convenience alias for dynamic launches. +Convenience alias for EVM dynamic launches. - Internally forwards to `POST /v1/launches` and forces `auction.type = "dynamic"`. -- Same auth, request shape, response, and error model as `POST /v1/launches`. +- Solana is not supported on this alias route. --- ### `GET /v1/launches/:launchId` -Returns current launch transaction status. +Returns current launch transaction status for EVM launches only. #### Path param @@ -208,14 +218,13 @@ Returns current launch transaction status. - `422 INVALID_LAUNCH_ID` - `502 CHAIN_LOOKUP_FAILED` - `502 CREATE_EVENT_NOT_FOUND` -- `500 INTERNAL_ERROR` (message is generic) +- `500 INTERNAL_ERROR` --- ### `GET /v1/capabilities` -Returns deployment profile and per-chain capability matrix. -Governance support is chain-specific. Check `governanceModes` and `governanceEnabled` per chain. +Returns deployment profile and supported create capabilities. #### Response `200` @@ -228,6 +237,14 @@ Governance support is chain-specific. Check `governanceModes` and `governanceEna - `migrationModes` - `governanceModes` - `governanceEnabled` +- `solana`: + - `enabled` + - `supportedNetworks` + - `unsupportedNetworks` + - `dedicatedRouteInputAliases` + - `creationOnly` + - `numeraireAddress` + - `priceResolutionModes` #### Error responses @@ -250,45 +267,20 @@ Liveness probe. ### `GET /ready` -Dependency readiness probe (RPC checks for configured chains). +Dependency readiness probe. #### Response -- `200` when all chains are reachable -- `503` when any chain check fails +- `200` when all configured EVM chain checks and Solana readiness checks pass +- `503` when any check fails - body: - `status: "ready" | "degraded"` - - `checks[]: { chainId, ok, latestBlock? , error? }` - - when `ok=false`, `error` is intentionally generic (`"dependency unavailable"`) - -#### Error responses - -- `401 UNAUTHORIZED` -- `429 RATE_LIMITED` - ---- - -### `GET /metrics` - -Basic service metrics snapshot for operational visibility. - -#### Response `200` - -- `startedAt` -- `uptimeSec` -- `http.totalRequests` -- `http.byStatusClass` -- `http.avgDurationMs` - -#### Error responses - -- `401 UNAUTHORIZED` -- `429 RATE_LIMITED` - -## Request/response examples + - `checks[]` for configured EVM chains + - `solana: { enabled, ok, network?, checks[] }` -See: +#### Solana readiness checks -- `README.md` for quick examples -- `docs/custom-curves.md` for detailed multicurve payloads -- `docs/openapi.yaml` for machine-readable schemas +- RPC reachable +- latest blockhash fetch +- initializer config account decode +- address lookup table presence when ALT is enabled diff --git a/docs/configuration.md b/docs/configuration.md index 6d03750..7662012 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,72 +3,138 @@ Runtime configuration is TypeScript-first: - Canonical non-secret settings live in `doppler.config.ts`. -- Secrets and operational runtime overrides come from environment variables. +- Secrets and runtime overrides come from environment variables. - The template object in `doppler.config.ts` must satisfy `DopplerTemplateConfigV1`. - If the shape drifts, `npm run typecheck` / `npm run build` fails. ## Canonical typed config Edit `doppler.config.ts` for: -- chain map and per-chain capabilities -- default chain selection -- non-secret service defaults (port, logging, idempotency defaults, pricing defaults) +- EVM chain map and per-chain capabilities +- default EVM chain selection +- non-secret service defaults (port, logging, idempotency, pricing) ## Required environment variables - `API_KEY` - `PRIVATE_KEY` -## Optional environment overrides +## Optional core environment overrides -- `PORT` (default from `doppler.config.ts`) -- `DEPLOYMENT_MODE` (default from `doppler.config.ts`) +- `PORT` +- `DEPLOYMENT_MODE` - allowed: `standalone`, `shared` - - if unset and `NODE_ENV=production`, deployment mode defaults to `shared` -- `DEFAULT_CHAIN_ID` (must exist in `doppler.config.ts`) + - defaults to `shared` when `NODE_ENV=production` and no explicit mode is set +- `DEFAULT_CHAIN_ID` - `RPC_URL` - - overrides `rpcUrl` for `DEFAULT_CHAIN_ID` only + - overrides the configured RPC only for `DEFAULT_CHAIN_ID` - `DEFAULT_NUMERAIRE_ADDRESS` - - overrides `defaultNumeraireAddress` for `DEFAULT_CHAIN_ID` only -- `READY_RPC_TIMEOUT_MS` (default from `doppler.config.ts`) -- `LOG_LEVEL` (default from `doppler.config.ts`) + - overrides the configured numeraire only for `DEFAULT_CHAIN_ID` +- `READY_RPC_TIMEOUT_MS` +- `LOG_LEVEL` - `CORS_ORIGINS` - - Comma-separated allowlist. - - Empty means CORS is disabled. - `API_KEYS` - - Optional comma-separated additional API keys. -- `RATE_LIMIT_MAX` (default from `doppler.config.ts`) -- `RATE_LIMIT_WINDOW_MS` (default from `doppler.config.ts`) +- `RATE_LIMIT_MAX` +- `RATE_LIMIT_WINDOW_MS` ## Idempotency environment variables -- `IDEMPOTENCY_ENABLED` (default from `doppler.config.ts`) -- `IDEMPOTENCY_BACKEND` (default from `doppler.config.ts`, allowed: `file`, `redis`) -- `IDEMPOTENCY_REQUIRE_KEY` (default from `doppler.config.ts`) +- `IDEMPOTENCY_ENABLED` +- `IDEMPOTENCY_BACKEND` + - allowed: `file`, `redis` +- `IDEMPOTENCY_REQUIRE_KEY` - forced to `true` when `DEPLOYMENT_MODE=shared` -- `IDEMPOTENCY_TTL_MS` (default from `doppler.config.ts`) -- `IDEMPOTENCY_STORE_PATH` (default from `doppler.config.ts`) -- `IDEMPOTENCY_REDIS_LOCK_TTL_MS` (default from `doppler.config.ts`) - - TTL for cross-replica in-flight idempotency lock - - set this to at least your max expected create-launch duration -- `IDEMPOTENCY_REDIS_LOCK_REFRESH_MS` (default from `doppler.config.ts`) - - heartbeat interval for refreshing the Redis in-flight lock - - must be lower than `IDEMPOTENCY_REDIS_LOCK_TTL_MS` +- `IDEMPOTENCY_TTL_MS` +- `IDEMPOTENCY_STORE_PATH` +- `IDEMPOTENCY_REDIS_LOCK_TTL_MS` +- `IDEMPOTENCY_REDIS_LOCK_REFRESH_MS` -## Pricing environment variables - -- `PRICE_ENABLED` (default from `doppler.config.ts`) -- `PRICE_PROVIDER` (default from `doppler.config.ts`) -- `PRICE_BASE_URL` (default from `doppler.config.ts`) -- `PRICE_TIMEOUT_MS` (default from `doppler.config.ts`) -- `PRICE_CACHE_TTL_MS` (default from `doppler.config.ts`) -- `PRICE_API_KEY` (optional) -- `PRICE_COINGECKO_ASSET_ID` (default from `doppler.config.ts`) +Redis-backed idempotency writes `in_progress` markers for EVM flows and also persists Solana +`SOLANA_LAUNCH_IN_DOUBT` results so retries fail closed with the original error details. -## Multichain configuration +## Pricing environment variables -Define chains directly in `doppler.config.ts`: +- `PRICE_ENABLED` +- `PRICE_PROVIDER` +- `PRICE_BASE_URL` +- `PRICE_TIMEOUT_MS` +- `PRICE_CACHE_TTL_MS` +- `PRICE_API_KEY` +- `PRICE_COINGECKO_ASSET_ID` + +## Solana environment variables + +- `SOLANA_ENABLED` +- `SOLANA_DEFAULT_NETWORK` + - allowed: `solanaDevnet`, `solanaMainnetBeta` + - this uses canonical internal names only +- `SOLANA_DEVNET_RPC_URL` +- `SOLANA_DEVNET_WS_URL` +- `SOLANA_MAINNET_BETA_RPC_URL` + - optional scaffolded setting +- `SOLANA_MAINNET_BETA_WS_URL` + - optional scaffolded setting +- `SOLANA_KEYPAIR` + - JSON array of 64 secret-key bytes +- `SOLANA_CONFIRM_TIMEOUT_MS` +- `SOLANA_DEVNET_USE_ALT` +- `SOLANA_DEVNET_ALT_ADDRESS` + - optional override when ALT is enabled +- `SOLANA_PRICE_MODE` + - allowed: `required`, `fixed`, `coingecko` +- `SOLANA_FIXED_NUMERAIRE_PRICE_USD` + - required when `SOLANA_PRICE_MODE=fixed` +- `SOLANA_COINGECKO_ASSET_ID` + - defaults to `solana` + +### Solana startup guardrails + +When `SOLANA_ENABLED=true`, startup fails fast for static config errors: + +- missing `SOLANA_KEYPAIR` +- missing `SOLANA_DEVNET_RPC_URL` +- missing `SOLANA_DEVNET_WS_URL` +- invalid `SOLANA_KEYPAIR` format +- invalid `SOLANA_DEFAULT_NETWORK` +- invalid `SOLANA_PRICE_MODE` +- missing `SOLANA_FIXED_NUMERAIRE_PRICE_USD` when `SOLANA_PRICE_MODE=fixed` + +### Solana runtime notes + +- Only `solanaDevnet` is executable in this API profile. +- `solanaMainnetBeta` is scaffolded in config and capabilities but returns `501 SOLANA_NETWORK_UNSUPPORTED`. +- WSOL is the only supported Solana numeraire. +- Solana price resolution precedence is: + 1. request `pricing.numerairePriceUsd` + 2. `SOLANA_FIXED_NUMERAIRE_PRICE_USD` + 3. CoinGecko using `SOLANA_COINGECKO_ASSET_ID` + 4. otherwise `422 SOLANA_NUMERAIRE_PRICE_REQUIRED` + +## Live test environment variables + +- `LIVE_TEST_ENABLE` +- `LIVE_TEST_VERBOSE` +- `LIVE_NUMERAIRE_PRICE_USD` +- `LIVE_TEST_MIN_BALANCE_ETH` +- `LIVE_TEST_ESTIMATED_TX_COST_ETH` +- `LIVE_TEST_ESTIMATED_OVERHEAD_ETH` +- `LIVE_TEST_MIN_BALANCE_SOL` +- `LIVE_TEST_ESTIMATED_TX_COST_SOL` +- `LIVE_TEST_ESTIMATED_OVERHEAD_SOL` + +### Solana live test notes + +- `npm run test:live:solana` runs the full Solana devnet matrix. +- `npm run test:live:solana:devnet` is an explicit devnet alias. +- `npm run test:live:solana:defaults` runs the basic/default Solana create coverage. +- `npm run test:live:solana:random` runs randomized Solana parameter coverage. +- `npm run test:live:solana:failing` runs Solana route/policy failures without submitting launches. +- Set `LIVE_TEST_VERBOSE=true` for full per-launch output instead of the concise summary mode. +- The Solana readiness gate estimates required payer balance in SOL; override it with `LIVE_TEST_MIN_BALANCE_SOL` or tune the per-launch estimate with `LIVE_TEST_ESTIMATED_TX_COST_SOL` and `LIVE_TEST_ESTIMATED_OVERHEAD_SOL`. + +## Multichain EVM configuration + +Define EVM chains directly in `doppler.config.ts`: ```ts chains: { @@ -91,36 +157,28 @@ chains: { } ``` -### Notes +### EVM notes - Keys must be numeric chain IDs. -- `DEFAULT_CHAIN_ID` must reference an existing key in `doppler.config.ts`. -- `RPC_URL` only overrides the configured `rpcUrl` for `DEFAULT_CHAIN_ID`. -- `launchId` is always `:` to preserve cross-chain identity. -- `governance` create behavior is binary: - - `false` or omitted uses no governance - - `true` uses default token-holder governance (OpenZeppelin Governor) via the governance factory when `governanceModes` includes `default` and `governanceEnabled=true` -- Recommendation: configure `auctionTypes` with `["multicurve", "dynamic"]` for V4-capable deployments and reserve `["static"]` for networks without Uniswap V4 support. -- Dynamic launches require `migrationModes` to include `"uniswapV2"` and/or `"uniswapV4"`. -- `uniswapV3` migration is not supported and returns `501 MIGRATION_NOT_IMPLEMENTED` if requested. -- If you intentionally want the V3 static path on Base Sepolia for testing, include `"static"` in that chain's `auctionTypes` list. +- `DEFAULT_CHAIN_ID` must reference an existing configured EVM chain. +- `launchId` is `:` for EVM launches. +- `RPC_URL` only overrides the default chain's RPC. +- `uniswapV3` migration is not supported and returns `501 MIGRATION_NOT_IMPLEMENTED`. ## Redis environment variables - `REDIS_URL` -- `REDIS_KEY_PREFIX` (default from `doppler.config.ts`) +- `REDIS_KEY_PREFIX` ## Deployment mode guidance -- `standalone`: one API instance owns its own local state and does not need cross-instance coordination. -- `shared`: multiple API instances can serve the same workload safely by coordinating through Redis. +- `standalone`: one API instance owns its own local state. +- `shared`: multiple API instances coordinate through Redis. When `DEPLOYMENT_MODE=standalone`: - Redis is optional. - File-backed idempotency is the default. -- Good fit for one API instance, one signer, and a durable local filesystem. -- Redis is recommended if you want stronger crash/restart recovery around create requests. When `DEPLOYMENT_MODE=shared`: @@ -129,11 +187,4 @@ When `DEPLOYMENT_MODE=shared`: - `IDEMPOTENCY_BACKEND` must be `redis`. - create endpoints require `Idempotency-Key`. - rate-limiter state uses Redis for cross-replica consistency. -- tx nonce submission uses a Redis-backed distributed signer lock for cross-replica coordination. - startup fails fast if Redis cannot be reached. - -`NODE_ENV=production` with no explicit `DEPLOYMENT_MODE` resolves to `shared`. - -Redis-backed idempotency writes an `in_progress` marker before create submit. -If a process crashes/restarts before completion, retries with the same key fail closed with -`409 IDEMPOTENCY_KEY_IN_DOUBT` until operators verify prior attempt status. diff --git a/docs/errors.md b/docs/errors.md index a97b0c4..f925826 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -16,29 +16,25 @@ All errors are returned as: - `details` is optional. - Validation failures return `422 INVALID_REQUEST` with structured zod details. -- 5xx responses return a generic client-safe message (`"Internal server error"`). - Full diagnostics remain in server logs. +- `5xx` responses return a generic client-safe message (`"Internal server error"`). ## Common status codes -- `401` unauthorized (`x-api-key` missing/invalid) -- `429` rate limit exceeded (`RATE_LIMITED`) +- `401` unauthorized +- `409` idempotency or in-doubt create failure - `422` validation or business-rule failure -- `501` planned but not implemented functionality -- `502` upstream or chain lookup failures +- `429` rate limit exceeded +- `501` unsupported but intentionally scaffolded functionality +- `502` upstream, simulation, or submission failure - `500` internal errors -## Operational - -- `RATE_LIMITED` - -## Known domain error codes +## Known error codes ### Authentication - `UNAUTHORIZED` -### Launch creation and policy +### EVM launch creation and policy - `AUCTION_TYPE_UNSUPPORTED` - `MIGRATION_NOT_IMPLEMENTED` @@ -46,23 +42,28 @@ All errors are returned as: - `GOVERNANCE_MODE_UNSUPPORTED` - `NUMERAIRE_REQUIRED` - `INVALID_ECONOMICS` - - includes allocation errors such as: - - `tokensForSale` below 20% market minimum when using split allocations - - non-market allocation sums not matching `totalSupply - tokensForSale` - - duplicate non-market allocation addresses - - more than 10 allocation addresses - `INVALID_BIGINT` - `INVALID_FEE_BENEFICIARIES` -- `IDEMPOTENCY_KEY_REQUIRED` -- `IDEMPOTENCY_KEY_REUSE_MISMATCH` -- `IDEMPOTENCY_KEY_IN_DOUBT` - - returned as `409` when a previous same-key create attempt may have submitted but did not finalize idempotency state - -Dynamic-specific policy notes: -- Dynamic launches require `migration.type="uniswapV2"` or `migration.type="uniswapV4"`. -- `migration.type="uniswapV4"` requires `migration.fee` and `migration.tickSpacing`. -- `migration.type="uniswapV3"` currently returns `501 MIGRATION_NOT_IMPLEMENTED`. +### Solana creation + +- `SOLANA_NETWORK_UNSUPPORTED` + - returned when Solana is disabled or `solanaMainnetBeta` is requested +- `SOLANA_NUMERAIRE_UNSUPPORTED` + - only WSOL is supported in this iteration +- `SOLANA_NUMERAIRE_PRICE_REQUIRED` + - no request override, fixed price, or CoinGecko price was available +- `SOLANA_INVALID_METADATA` +- `SOLANA_INVALID_CURVE` +- `SOLANA_NOT_READY` + - readiness gates failed before create +- `SOLANA_SIMULATION_FAILED` + - returned when simulation fails before submit +- `SOLANA_SUBMISSION_FAILED` + - returned when submit fails or a submitted transaction is later rejected +- `SOLANA_LAUNCH_IN_DOUBT` + - returned as `409` when submit succeeded but confirmation remained ambiguous + - includes `details: { launchId, signature, explorerUrl }` ### Pricing @@ -81,6 +82,15 @@ Dynamic-specific policy notes: - `CREATE_EVENT_NOT_FOUND` - `CREATE_TX_DECODE_FAILED` +### Idempotency + +- `IDEMPOTENCY_KEY_REQUIRED` +- `IDEMPOTENCY_KEY_REUSE_MISMATCH` +- `IDEMPOTENCY_KEY_IN_DOUBT` + - EVM retry failed closed because an earlier same-key request may have submitted +- `SOLANA_LAUNCH_IN_DOUBT` + - same-key Solana retries fail closed with the original in-doubt details + ### Config - `MISSING_ENV` @@ -88,8 +98,7 @@ Dynamic-specific policy notes: - `REDIS_UNAVAILABLE` - `UNSUPPORTED_CHAIN` -Shared-mode guardrail notes: +## Operational notes -- `DEPLOYMENT_MODE=shared` requires `REDIS_URL`. -- `DEPLOYMENT_MODE=shared` requires `IDEMPOTENCY_ENABLED=true` and `IDEMPOTENCY_BACKEND=redis`. -- startup returns `REDIS_UNAVAILABLE` if Redis is configured but unreachable. +- `GET /ready` intentionally sanitizes dependency failure messages to `"dependency unavailable"`. +- Shared mode requires Redis-backed idempotency and reachable Redis at startup. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9cdb89c..141968f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3,15 +3,13 @@ info: title: Doppler Launch API version: 0.1.0 description: | - REST API for creating Doppler launches with multicurve, dynamic, and static support. + REST API for creating Doppler launches across EVM and Solana create flows. v1 deployment profile supports: - - multicurve/static with noOp migration - - dynamic with uniswapV2 or uniswapV4 migration - - binary governance toggle (no governance or default token-holder governance using OpenZeppelin Governor) - Prefer multicurve on V4-capable chains; static is a compatibility path. - Multicurve initializer modes: standard, scheduled, decay, rehype. - tokensForSale is configurable; when omitted it defaults to totalSupply (100% market allocation). - Any remainder (totalSupply - tokensForSale) is non-market allocation and uses vesting defaults. + - EVM multicurve/static with noOp migration + - EVM dynamic with uniswapV2 or uniswapV4 migration + - Solana devnet XYK create-only launches on /v1/solana/launches or /v1/launches + - binary governance toggle for EVM only + Prefer multicurve on V4-capable EVM chains; static is a compatibility path. servers: - url: http://localhost:3000 security: @@ -25,7 +23,7 @@ paths: /v1/launches: post: tags: [Launches] - summary: Create a launch + summary: Create an EVM or Solana launch operationId: createLaunch parameters: - name: Idempotency-Key @@ -39,14 +37,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateLaunchRequest' + oneOf: + - $ref: '#/components/schemas/CreateLaunchRequest' + - $ref: '#/components/schemas/GenericSolanaCreateLaunchRequest' responses: '200': description: Launch submitted content: application/json: schema: - $ref: '#/components/schemas/CreateLaunchResponse' + oneOf: + - $ref: '#/components/schemas/CreateLaunchResponse' + - $ref: '#/components/schemas/SolanaCreateLaunchResponse' '401': $ref: '#/components/responses/Error' '429': @@ -55,10 +57,55 @@ paths: $ref: '#/components/responses/Error' '501': $ref: '#/components/responses/Error' + '503': + $ref: '#/components/responses/Error' + '502': + $ref: '#/components/responses/Error' '500': $ref: '#/components/responses/Error' '409': $ref: '#/components/responses/Error' + /v1/solana/launches: + post: + tags: [Launches] + summary: Create a Solana launch + operationId: createSolanaLaunch + parameters: + - name: Idempotency-Key + in: header + required: false + schema: + type: string + description: Reuse the same key for safe retries of identical create payloads (required in shared mode, recommended in standalone mode). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DedicatedSolanaCreateLaunchRequest' + responses: + '200': + description: Solana launch submitted + content: + application/json: + schema: + $ref: '#/components/schemas/SolanaCreateLaunchResponse' + '401': + $ref: '#/components/responses/Error' + '409': + $ref: '#/components/responses/Error' + '422': + $ref: '#/components/responses/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + '501': + $ref: '#/components/responses/Error' + '502': + $ref: '#/components/responses/Error' + '503': + $ref: '#/components/responses/Error' + '500': + $ref: '#/components/responses/Error' /v1/launches/multicurve: post: tags: [Launches] @@ -697,8 +744,139 @@ components: - $ref: '#/components/schemas/MulticurveAuction' - $ref: '#/components/schemas/StaticAuction' - $ref: '#/components/schemas/DynamicAuction' + SolanaNetwork: + type: string + enum: [solanaDevnet, solanaMainnetBeta] + DedicatedSolanaNetwork: + type: string + enum: [devnet, mainnet-beta] + SolanaAddress: + type: string + SolanaTokenMetadata: + type: object + additionalProperties: false + properties: + name: + type: string + minLength: 1 + maxLength: 32 + symbol: + type: string + minLength: 1 + maxLength: 10 + tokenURI: + type: string + minLength: 1 + maxLength: 200 + required: [name, symbol, tokenURI] + SolanaEconomics: + type: object + additionalProperties: false + properties: + totalSupply: + type: string + pattern: '^\d+$' + required: [totalSupply] + SolanaPairing: + type: object + additionalProperties: false + properties: + numeraireAddress: + $ref: '#/components/schemas/SolanaAddress' + SolanaPricing: + type: object + additionalProperties: false + properties: + numerairePriceUsd: + type: number + exclusiveMinimum: 0 + SolanaMigration: + type: object + additionalProperties: false + properties: + type: + type: string + const: noOp + required: [type] + SolanaCurveConfig: + type: object + additionalProperties: false + properties: + type: + type: string + const: range + marketCapStartUsd: + type: number + exclusiveMinimum: 0 + marketCapEndUsd: + type: number + exclusiveMinimum: 0 + required: [type, marketCapStartUsd, marketCapEndUsd] + SolanaAuction: + type: object + additionalProperties: false + properties: + type: + type: string + const: xyk + curveConfig: + $ref: '#/components/schemas/SolanaCurveConfig' + curveFeeBps: + type: integer + minimum: 0 + maximum: 10000 + allowBuy: + type: boolean + allowSell: + type: boolean + required: [type, curveConfig] + DedicatedSolanaCreateLaunchRequest: + type: object + additionalProperties: false + properties: + network: + $ref: '#/components/schemas/DedicatedSolanaNetwork' + tokenMetadata: + $ref: '#/components/schemas/SolanaTokenMetadata' + economics: + $ref: '#/components/schemas/SolanaEconomics' + pairing: + $ref: '#/components/schemas/SolanaPairing' + pricing: + $ref: '#/components/schemas/SolanaPricing' + governance: + type: boolean + enum: [false] + migration: + $ref: '#/components/schemas/SolanaMigration' + auction: + $ref: '#/components/schemas/SolanaAuction' + required: [tokenMetadata, economics, auction] + GenericSolanaCreateLaunchRequest: + type: object + additionalProperties: false + properties: + network: + $ref: '#/components/schemas/SolanaNetwork' + tokenMetadata: + $ref: '#/components/schemas/SolanaTokenMetadata' + economics: + $ref: '#/components/schemas/SolanaEconomics' + pairing: + $ref: '#/components/schemas/SolanaPairing' + pricing: + $ref: '#/components/schemas/SolanaPricing' + governance: + type: boolean + enum: [false] + migration: + $ref: '#/components/schemas/SolanaMigration' + auction: + $ref: '#/components/schemas/SolanaAuction' + required: [network, tokenMetadata, economics, auction] CreateLaunchRequest: type: object + additionalProperties: false properties: chainId: type: integer @@ -843,6 +1021,72 @@ components: feeBeneficiariesSource, ] required: [launchId, chainId, txHash, statusUrl, predicted, effectiveConfig] + SolanaCreateLaunchResponse: + type: object + properties: + launchId: + type: string + description: Base58 launch PDA + network: + $ref: '#/components/schemas/SolanaNetwork' + signature: + type: string + explorerUrl: + type: string + predicted: + type: object + properties: + tokenAddress: + $ref: '#/components/schemas/SolanaAddress' + launchAuthorityAddress: + $ref: '#/components/schemas/SolanaAddress' + baseVaultAddress: + $ref: '#/components/schemas/SolanaAddress' + quoteVaultAddress: + $ref: '#/components/schemas/SolanaAddress' + required: + [tokenAddress, launchAuthorityAddress, baseVaultAddress, quoteVaultAddress] + effectiveConfig: + type: object + properties: + tokensForSale: + type: string + allocationAmount: + type: string + allocationLockMode: + type: string + enum: [none] + numeraireAddress: + $ref: '#/components/schemas/SolanaAddress' + numerairePriceUsd: + type: number + curveVirtualBase: + type: string + curveVirtualQuote: + type: string + curveFeeBps: + type: integer + allowBuy: + type: boolean + allowSell: + type: boolean + tokenDecimals: + type: integer + required: + [ + tokensForSale, + allocationAmount, + allocationLockMode, + numeraireAddress, + numerairePriceUsd, + curveVirtualBase, + curveVirtualQuote, + curveFeeBps, + allowBuy, + allowSell, + tokenDecimals, + ] + required: [launchId, network, signature, explorerUrl, predicted, effectiveConfig] LaunchStatusResult: type: object properties: @@ -928,7 +1172,43 @@ components: governanceModes, governanceEnabled, ] - required: [defaultChainId, pricing, chains] + solana: + type: object + properties: + enabled: + type: boolean + supportedNetworks: + type: array + items: + $ref: '#/components/schemas/SolanaNetwork' + unsupportedNetworks: + type: array + items: + $ref: '#/components/schemas/SolanaNetwork' + dedicatedRouteInputAliases: + type: array + items: + $ref: '#/components/schemas/DedicatedSolanaNetwork' + creationOnly: + type: boolean + numeraireAddress: + $ref: '#/components/schemas/SolanaAddress' + priceResolutionModes: + type: array + items: + type: string + enum: [request, fixed, coingecko] + required: + [ + enabled, + supportedNetworks, + unsupportedNetworks, + dedicatedRouteInputAliases, + creationOnly, + numeraireAddress, + priceResolutionModes, + ] + required: [defaultChainId, pricing, chains, solana] ReadyCheck: type: object properties: @@ -941,6 +1221,17 @@ components: error: type: string required: [chainId, ok] + SolanaReadyCheck: + type: object + properties: + name: + type: string + enum: [rpcReachable, latestBlockhash, initializerConfig, addressLookupTable] + ok: + type: boolean + error: + type: string + required: [name, ok] ReadyResponse: type: object properties: @@ -951,7 +1242,22 @@ components: type: array items: $ref: '#/components/schemas/ReadyCheck' - required: [status, checks] + solana: + type: object + properties: + enabled: + type: boolean + ok: + type: boolean + network: + type: string + enum: [solanaDevnet] + checks: + type: array + items: + $ref: '#/components/schemas/SolanaReadyCheck' + required: [enabled, ok, checks] + required: [status, checks, solana] MetricsResponse: type: object properties: diff --git a/package-lock.json b/package-lock.json index 01915c7..94a638e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@fastify/cors": "^11.2.0", "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.4", - "@whetstone-research/doppler-sdk": "^0.0.22", + "@whetstone-research/doppler-sdk": "^1.0.7", "dotenv": "^16.4.5", "fastify": "^5.8.1", "fastify-plugin": "^5.1.0", @@ -1721,6 +1721,1048 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@solana-program/system": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.12.0.tgz", + "integrity": "sha512-ZnAAWeGVMWNtJhw3GdifI2HnhZ0A0H0qs8tBkcFvxp/8wIavvO+GOM4Jd0N22u2+Lni2zcwvcrxrsxj6Mjphng==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^6.1.0" + } + }, + "node_modules/@solana-program/token": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.12.0.tgz", + "integrity": "sha512-hnidRNuFhmqUdW5aWkKTJ+cdzuotVMNwLsTyAk0Nd8VjLDld+vQC0fugHWqm5GPrvYe0hCNAhtpJcZVnNp7rOA==", + "license": "Apache-2.0", + "dependencies": { + "@solana-program/system": "^0.12.0" + }, + "peerDependencies": { + "@solana/kit": "^6.1.0" + } + }, + "node_modules/@solana/accounts": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-6.8.0.tgz", + "integrity": "sha512-rXjFYVopaEw1H2PTBQbRjKr+0i4EFuBEhRT5E0dI4cMaabSb4KKypC2gaf47+6cjU3hMlM1AcsyIs72/MqAVBw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/rpc-spec": "6.8.0", + "@solana/rpc-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/addresses": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-6.8.0.tgz", + "integrity": "sha512-xVlA0DNX1LVfTueVsbhxDDoqr1VxeXvgJEh2GcIN/vcJPhY3GE3AYtjTbJJmTDgPrzOccI0t6ElVb1gelJH/PQ==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/nominal-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/assertions": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-6.8.0.tgz", + "integrity": "sha512-OU6prCq39fSvGL8xY1C/9vhghasvAkMiRlituzJxzJpZRfpVRrwhzLd6P5NPAPoQ28qKcenA50kFdw9+ZyneJQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-6.8.0.tgz", + "integrity": "sha512-qCSAaw1qszeQflavkIM7c21qJ3BHReP/qgDelZbhsEXpZc852CCZM00FOIWuxePr6X+JjSNqJquxwdDSoZe7Bw==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "6.8.0", + "@solana/codecs-data-structures": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/options": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-core": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-6.8.0.tgz", + "integrity": "sha512-udFO8TrvzgROonwX3rY3E2SG675RehILNb4ZYcKlf1mL7vkDJ9bEJnBxi87AEwl8RWZFTl+MhT0MmrJnbpvdug==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-6.8.0.tgz", + "integrity": "sha512-lHr0F+nNwgm9c+tWQX398yzYh1qDi7QSCJpY9MQ2azW4FfY2IyPSo7bqzTaWNnJh9pmJx3ZI6jHfXBnLD5k/SQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-6.8.0.tgz", + "integrity": "sha512-ebf4f1D19EAe0uhdUYOCEYnn5+EellsBxbJ42tM2yYEoIBVz5FoBBC0gSsq+UTNbQHFa7XagyBT3LewxXttiTQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "6.8.0", + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-strings": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-6.8.0.tgz", + "integrity": "sha512-Rpk5NVhbKYcPnE7wz3IpTp0GVNVs0IYKdmyzByiimgPTiII8eb8ay4wQiYHGHrpYh62hD14Qy3GiGDFgipRKqA==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "fastestsmallesttextencoderdecoder": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-6.8.0.tgz", + "integrity": "sha512-HRTrLgTn0c99GKz4v4IKgz2+6soaRY1mh2tLW4sk1Fe4Zzv85Q6ZLK1mXrVGL73z1apyHDrr9/Sd/9ZhUsUvpA==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "commander": "14.0.3" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/fast-stable-stringify": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-6.8.0.tgz", + "integrity": "sha512-lZa3Qnsn+9ew6rHTXkPc+uqSa3i+AWqSBhV6oYxxBc+smvuxovItU4TPIs30cTfA7lAP+j+oYAQtUDu2dLy0hA==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/functional": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-6.8.0.tgz", + "integrity": "sha512-oMSAD/8w9ujx7OplvwRWwHHFnaaxi/Xrji1XH3xAB+gzxupUpBbOmgxQ+e84x+9VN8QWk5aU3L7gmCqdTAR6OA==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instruction-plans": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-6.8.0.tgz", + "integrity": "sha512-osAsY8ozqohrcTcHlG1EmO3i9flc0eESMIy9akTHyVvqk915gZgkaTmt4IjcYSwBGt7i+Rh8TmLj27RrTpCKvg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/instructions": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/promises": "6.8.0", + "@solana/transaction-messages": "6.8.0", + "@solana/transactions": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instructions": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-6.8.0.tgz", + "integrity": "sha512-dTtykhS9IeN3npCfnd7wSS6KmKAh54+g90JRtLYy5/31L2Zvunf3AJz2QUk58vgsAGZ5fuoiMyhCxRJm4rHUBQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "6.8.0", + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/keys": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-6.8.0.tgz", + "integrity": "sha512-Wo8CnbrVfCP1Jbsb3ElMej/3dmMrl4ArPhI1mDcqIIz/O4j4HmxZYbn2BCWtnV9V/LPM638EMO2r1x6GzDNrPA==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/nominal-types": "6.8.0", + "@solana/promises": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/kit": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-6.8.0.tgz", + "integrity": "sha512-+McC1aCgcUBdM7Cd7U6k2ZHJ9OKCy5mzpb0XWrhkrgsFxT0QoRr0AcWJc85o6tIDfG6Jz7vVhbS3l8ugYz2Vzw==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "6.8.0", + "@solana/addresses": "6.8.0", + "@solana/codecs": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/functional": "6.8.0", + "@solana/instruction-plans": "6.8.0", + "@solana/instructions": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/offchain-messages": "6.8.0", + "@solana/plugin-core": "6.8.0", + "@solana/plugin-interfaces": "6.8.0", + "@solana/program-client-core": "6.8.0", + "@solana/programs": "6.8.0", + "@solana/rpc": "6.8.0", + "@solana/rpc-api": "6.8.0", + "@solana/rpc-parsed-types": "6.8.0", + "@solana/rpc-spec-types": "6.8.0", + "@solana/rpc-subscriptions": "6.8.0", + "@solana/rpc-types": "6.8.0", + "@solana/signers": "6.8.0", + "@solana/subscribable": "6.8.0", + "@solana/sysvars": "6.8.0", + "@solana/transaction-confirmation": "6.8.0", + "@solana/transaction-messages": "6.8.0", + "@solana/transactions": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/nominal-types": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-6.8.0.tgz", + "integrity": "sha512-mLmHr92pM4mEfe49GUmZ5Ry0RMqtMuFQqZYnxQqhDKMcl+Wtt820ezxYgwPhqcMxRzfqaQSO3ZxpSB0RlLBa/Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/offchain-messages": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-6.8.0.tgz", + "integrity": "sha512-HoniTs2uoCHGicD0dTTJ3YBhLZC9URxdXXUf0CHalLFwAidF9iNuB8dsuKk16Euu68L4/ERKKGfyC0QobBvahw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-data-structures": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/nominal-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/options": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-6.8.0.tgz", + "integrity": "sha512-T5441HHeucFaLtaMAJQJl79T7mX007oAFPunpPebBphRvCXGv+qQwQvqa4HkYct6Jf2O0aKLBL9GSe/kfdCk9A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "6.8.0", + "@solana/codecs-data-structures": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-core": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-6.8.0.tgz", + "integrity": "sha512-kdqFIhQvJP2BDUsMOIbor35esj8u78SO33Xv0Wmo+uTRg6yKONKVK53ghw235pWrinOT4f0VnVe6MN6ciYiQVA==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-interfaces": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-interfaces/-/plugin-interfaces-6.8.0.tgz", + "integrity": "sha512-4olaMKGUVA7wG6BBWM5A31bQsUWBlfcL1pjhq6ZTqVEJ7vshHXGwHVlWYXYyYn9ixozGDpGSl553yaRY9jQwWw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/instruction-plans": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/rpc-spec": "6.8.0", + "@solana/rpc-subscriptions-spec": "6.8.0", + "@solana/rpc-types": "6.8.0", + "@solana/signers": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/program-client-core": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/program-client-core/-/program-client-core-6.8.0.tgz", + "integrity": "sha512-eOZtEnwl+vdiy9x/rFF89NDtnvt+Q3H04A/0u4GoHnt+fFkQG3JS+ChWG9c77izmpmRuz5C1GptOPDGNDnIUgQ==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "6.8.0", + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/instruction-plans": "6.8.0", + "@solana/instructions": "6.8.0", + "@solana/plugin-interfaces": "6.8.0", + "@solana/rpc-api": "6.8.0", + "@solana/signers": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/programs": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-6.8.0.tgz", + "integrity": "sha512-8hSKGfPTLX9Sm7KGV/UtiGCeSzptT/9vcjbodE+ZGHKFefo5vES4UAW+qD01LjL7IumGtMJvnfhCWt81qT/jbQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/promises": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-6.8.0.tgz", + "integrity": "sha512-kIypZG83ZbADbrAq9/LS7LuWlVxlgJSzIpic75+9IuAfC3k5/KSus8LrvggBkCzfAyIslrUh70iz4JcnzUZrOw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-6.8.0.tgz", + "integrity": "sha512-+jW4n9TDmBttY3bO3PdUo54GAnwFrd7UJsyfXoMgl/lWGQq5uddYDgnzQLtHOBP5zKslkR8h0RKkic0GZhMZrQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/fast-stable-stringify": "6.8.0", + "@solana/functional": "6.8.0", + "@solana/rpc-api": "6.8.0", + "@solana/rpc-spec": "6.8.0", + "@solana/rpc-spec-types": "6.8.0", + "@solana/rpc-transformers": "6.8.0", + "@solana/rpc-transport-http": "6.8.0", + "@solana/rpc-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-api": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-6.8.0.tgz", + "integrity": "sha512-v8ZKWgPtKbF6HeJcfC4ciwI8mwDCizBtRLYYjjHOu+9S9IJYyefQzsQxL5P8OjJPpI4gFauT6gsjQLo76BoojA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/rpc-parsed-types": "6.8.0", + "@solana/rpc-spec": "6.8.0", + "@solana/rpc-transformers": "6.8.0", + "@solana/rpc-types": "6.8.0", + "@solana/transaction-messages": "6.8.0", + "@solana/transactions": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-parsed-types": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-6.8.0.tgz", + "integrity": "sha512-jYddZviBSUYbuUKqvNthet7KbJVI7me6xfRH2znv1SjIpmvhSPJcGN5QrlHVOasHdzEWSpvZa5VYDfnqH3aYvA==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-6.8.0.tgz", + "integrity": "sha512-kE5uOspxCVFJKNUu73hlebGiAFosjfYXbbTXAbGKfksPzy84u1oJFC2IVIobLRnqUCw1x7oJcvfnX00Zs0Itpg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/rpc-spec-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec-types": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-6.8.0.tgz", + "integrity": "sha512-ebCWgiQbIgFOehU7PdRFmYCzda3Azc/qa2Y3P8gexSHSsDAO27VwS4E05XSY+a7cIL5MYmvUa1vpDynl1Rkakw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-6.8.0.tgz", + "integrity": "sha512-9CotreNZmKAP2z07FY1I7TPPvylKLFF5p4mujB5ZFMHQPp5JVQFVCmMIhSj5voZHAeYx7jdwJ2Kf0RDeClqJzA==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/fast-stable-stringify": "6.8.0", + "@solana/functional": "6.8.0", + "@solana/promises": "6.8.0", + "@solana/rpc-spec-types": "6.8.0", + "@solana/rpc-subscriptions-api": "6.8.0", + "@solana/rpc-subscriptions-channel-websocket": "6.8.0", + "@solana/rpc-subscriptions-spec": "6.8.0", + "@solana/rpc-transformers": "6.8.0", + "@solana/rpc-types": "6.8.0", + "@solana/subscribable": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-api": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-6.8.0.tgz", + "integrity": "sha512-cPJOsydyoqkztW3msEH09wPDYqxJcMvO6DBlvrboq6wGu1UjeP66w2eApzQ8POoQHxhyw+CfEXl1Gbu6kKwuMQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/rpc-subscriptions-spec": "6.8.0", + "@solana/rpc-transformers": "6.8.0", + "@solana/rpc-types": "6.8.0", + "@solana/transaction-messages": "6.8.0", + "@solana/transactions": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-6.8.0.tgz", + "integrity": "sha512-c3PpkorYwhAz1iuUfM5sLpZQi8xtZFGbaPbaPRELVeDjFSRzoa12KFnuQs4i9fbVbLy5Cnt1t23tf0bL2snZCQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/functional": "6.8.0", + "@solana/rpc-subscriptions-spec": "6.8.0", + "@solana/subscribable": "6.8.0", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-spec": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-6.8.0.tgz", + "integrity": "sha512-+t4L5q9qE6IVfunW3n1amA/3EswJr64pVqRF7234vCUuVUz4PgYfbqtEBV3KkA1o0NwEHHM3pXuofT63nBb8Bg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/promises": "6.8.0", + "@solana/rpc-spec-types": "6.8.0", + "@solana/subscribable": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transformers": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-6.8.0.tgz", + "integrity": "sha512-GzcFkllym7eXbw7grdE41MCb15CjkibrXtr7EFsf4d6LD9DRvzFj2ZRYywS2FB2ibVP0LUXXGk3vmtkZJjfajA==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/functional": "6.8.0", + "@solana/nominal-types": "6.8.0", + "@solana/rpc-spec-types": "6.8.0", + "@solana/rpc-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-6.8.0.tgz", + "integrity": "sha512-jw/L0q2motGcx7yo6KvkKJd2HGVg9gvViXatFloLl1XmHbkwE7+97YYmG17WRuM5xauzI/UGYOXNW7cEB+Uaxw==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0", + "@solana/rpc-spec": "6.8.0", + "@solana/rpc-spec-types": "6.8.0", + "undici-types": "^8.0.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http/node_modules/undici-types": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.1.0.tgz", + "integrity": "sha512-JlLXdMmH4kxyn2JPtGK/cajzKY7F15OKYG8sO5HfkIC1AC09sLUeptGFKjnMWnprDQ2EwzYDO3kgzkK3aaoHCA==", + "license": "MIT" + }, + "node_modules/@solana/rpc-types": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-6.8.0.tgz", + "integrity": "sha512-vACMV9VR2JsZGDcgaMOFN/dwLK57CsE+erassxxtF12sSPXJooz+Vu1vyY2Yp2EkCc7mDf7BNkTKvSXajbt+Qw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/nominal-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/signers": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-6.8.0.tgz", + "integrity": "sha512-7E1cAXBLOcz9kmHhzWdu5m3UJlJzxfwOl8irOMLJI6NnKB2EmU0B0h4I+Mlfs9w8Bfj0WQpUei21ammbNBq39g==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/instructions": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/nominal-types": "6.8.0", + "@solana/offchain-messages": "6.8.0", + "@solana/transaction-messages": "6.8.0", + "@solana/transactions": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/subscribable": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-6.8.0.tgz", + "integrity": "sha512-yj41Q97MiWrOmLj1iRFobvTdtU6H5wz5BlH5FHJg9lyapy1YQyaYF37MZx4LiUj4Ww0V3ReluIZTWWDBOJ53Jg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/sysvars": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-6.8.0.tgz", + "integrity": "sha512-pwfMpMNL6MSmm07eHQYdTdRdzmPOd+EuVCCaNLSYdWGpYcocVJiaLiNWRV3cXA5wPj/ZFkoUGtc1bo0v7H50lw==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-data-structures": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/rpc-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-confirmation": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-6.8.0.tgz", + "integrity": "sha512-R6rj8y/+kZqYJr8FR/fWxgi3Pw3eCiacUyjCPTVtdVe6i+hIiBApTGLzXrSRJmAMdpZrjYBZU1cG8C6oAb+B2A==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/promises": "6.8.0", + "@solana/rpc": "6.8.0", + "@solana/rpc-subscriptions": "6.8.0", + "@solana/rpc-types": "6.8.0", + "@solana/transaction-messages": "6.8.0", + "@solana/transactions": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-messages": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-6.8.0.tgz", + "integrity": "sha512-jsJu9mAcN1x7onKOeC4WEvYP04UVcnkOYu/9bMe+S9jqjL+3DMy9kFZpV5FBl+TPuTNJrtOqc6Gc28hUWyyp1A==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-data-structures": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/functional": "6.8.0", + "@solana/instructions": "6.8.0", + "@solana/nominal-types": "6.8.0", + "@solana/rpc-types": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transactions": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-6.8.0.tgz", + "integrity": "sha512-Q46m+o3C1yL2EIZBAP5B8ou2VZwHN9wTi+muIS6/giCKO3jwUtnTEbWcZEDMj2vxUb7P2WfwTluZb/VAWxlx7Q==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "6.8.0", + "@solana/codecs-core": "6.8.0", + "@solana/codecs-data-structures": "6.8.0", + "@solana/codecs-numbers": "6.8.0", + "@solana/codecs-strings": "6.8.0", + "@solana/errors": "6.8.0", + "@solana/functional": "6.8.0", + "@solana/instructions": "6.8.0", + "@solana/keys": "6.8.0", + "@solana/nominal-types": "6.8.0", + "@solana/rpc-types": "6.8.0", + "@solana/transaction-messages": "6.8.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/wallet-standard-features": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.3.0.tgz", + "integrity": "sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1873,15 +2915,66 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wallet-standard/base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.0.tgz", + "integrity": "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.0.tgz", + "integrity": "sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@whetstone-research/doppler-sdk": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@whetstone-research/doppler-sdk/-/doppler-sdk-0.0.22.tgz", - "integrity": "sha512-nukFWl6kto7FJoCDeZlR2zWqtYtrsBq+5fmHP9g3TSCXWbeDtQhglgkDNyTAM+HUP1uB22or8dn7E7FKOC9Rkw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@whetstone-research/doppler-sdk/-/doppler-sdk-1.0.7.tgz", + "integrity": "sha512-SoCACSic1c6D1pi9tCKxsHLfdvBy6PA10nigXE4hckRQisFEKdR7ug2iIzkNT6fSRNubbAZexcNEysHcbolvhQ==", + "license": "MIT", "dependencies": { + "@noble/hashes": "^2.0.1", + "@solana-program/system": "^0.12.0", + "@solana-program/token": "^0.12.0", + "@solana/kit": "^6.3.1", + "@solana/program-client-core": "^6.3.1", + "@solana/sysvars": "^6.5.0", + "@solana/wallet-standard-features": "^1.2.0", + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0", "viem": "^2.33.3" }, "engines": { "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@whetstone-research/doppler-sdk/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/abitype": { @@ -1990,6 +3083,18 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -1999,6 +3104,15 @@ "node": ">=0.10.0" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", diff --git a/package.json b/package.json index 9979fa4..c90b51b 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,18 @@ "test:live:multicurve:defaults": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=multicurve-defaults vitest run tests/live/create-and-verify.test.ts", "test:live:fees": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=fees vitest run tests/live/create-and-verify.test.ts", "test:live:governance": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=governance vitest run tests/live/create-and-verify.test.ts", + "test:live:solana": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=solana vitest run tests/live/create-and-verify.test.ts", + "test:live:solana:devnet": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=solana-devnet vitest run tests/live/create-and-verify.test.ts", + "test:live:solana:defaults": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=solana-defaults vitest run tests/live/create-and-verify.test.ts", + "test:live:solana:random": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=solana-random vitest run tests/live/create-and-verify.test.ts", + "test:live:solana:failing": "LIVE_TEST_ENABLE=true LIVE_TEST_FILTER=solana-failing vitest run tests/live/create-and-verify.test.ts", "test:all": "node scripts/test-runner.mjs --with-live" }, "dependencies": { "@fastify/cors": "^11.2.0", "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.4", - "@whetstone-research/doppler-sdk": "^0.0.22", + "@whetstone-research/doppler-sdk": "^1.0.7", "dotenv": "^16.4.5", "fastify": "^5.8.1", "fastify-plugin": "^5.1.0", diff --git a/src/app/routes/capabilities.get.ts b/src/app/routes/capabilities.get.ts index d052178..900df9e 100644 --- a/src/app/routes/capabilities.get.ts +++ b/src/app/routes/capabilities.get.ts @@ -1,6 +1,8 @@ import type { FastifyInstance } from 'fastify'; +import type { AppConfig } from '../../core/config'; import type { ChainRegistry } from '../../infra/chain/registry'; +import { SOLANA_CONSTANTS } from '../../modules/launches/solana'; import type { PricingService } from '../../modules/pricing/service'; const resolveMulticurveInitializers = (chain: ReturnType[number]) => { @@ -21,10 +23,19 @@ const resolveMulticurveInitializers = (chain: ReturnType[ export const registerCapabilitiesRoute = async ( fastify: FastifyInstance, + config: AppConfig, chainRegistry: ChainRegistry, pricingService: PricingService, ) => { fastify.get('/v1/capabilities', async () => { + const solanaPriceResolutionModes: Array<'request' | 'fixed' | 'coingecko'> = ['request']; + if (config.solana.fixedNumerairePriceUsd !== undefined) { + solanaPriceResolutionModes.push('fixed'); + } + if (config.solana.priceMode === 'coingecko') { + solanaPriceResolutionModes.push('coingecko'); + } + return { defaultChainId: chainRegistry.defaultChainId, pricing: { @@ -37,10 +48,21 @@ export const registerCapabilitiesRoute = async ( multicurveInitializers: chain.config.auctionTypes.includes('multicurve') ? resolveMulticurveInitializers(chain) : [], - migrationModes: chain.config.migrationModes, - governanceModes: chain.config.governanceModes, - governanceEnabled: chain.config.governanceEnabled, - })), + migrationModes: chain.config.migrationModes, + governanceModes: chain.config.governanceModes, + governanceEnabled: chain.config.governanceEnabled, + })), + solana: { + enabled: config.solana.enabled, + supportedNetworks: config.solana.enabled ? ['solanaDevnet'] : [], + unsupportedNetworks: config.solana.enabled + ? ['solanaMainnetBeta'] + : ['solanaDevnet', 'solanaMainnetBeta'], + dedicatedRouteInputAliases: ['devnet', 'mainnet-beta'], + creationOnly: true, + numeraireAddress: SOLANA_CONSTANTS.wsolMintAddress, + priceResolutionModes: solanaPriceResolutionModes, + }, }; }); }; diff --git a/src/app/routes/launches.post.ts b/src/app/routes/launches.post.ts index 7755801..534ca7b 100644 --- a/src/app/routes/launches.post.ts +++ b/src/app/routes/launches.post.ts @@ -1,14 +1,38 @@ import type { FastifyInstance } from 'fastify'; +import { AppError } from '../../core/errors'; import { createLaunchRequestSchema } from '../../modules/launches/schema'; +import { + isSolanaCanonicalNetwork, + parseGenericSolanaCreateLaunchRequest, +} from '../../modules/launches/solana'; import type { LaunchService } from '../../modules/launches/service'; +const parseLaunchRequest = (body: unknown) => { + if (typeof body === 'object' && body !== null && 'network' in body) { + const network = (body as { network?: unknown }).network; + if (isSolanaCanonicalNetwork(network)) { + return parseGenericSolanaCreateLaunchRequest(body); + } + + if (network === 'devnet' || network === 'mainnet-beta') { + throw new AppError( + 422, + 'INVALID_REQUEST', + 'Solana requests on POST /v1/launches must use network "solanaDevnet" or "solanaMainnetBeta"', + ); + } + } + + return createLaunchRequestSchema.parse(body); +}; + export const registerCreateLaunchRoute = async ( fastify: FastifyInstance, launchService: LaunchService, ) => { fastify.post('/v1/launches', async (request, reply) => { - const payload = createLaunchRequestSchema.parse(request.body); + const payload = parseLaunchRequest(request.body); const rawKey = request.headers['idempotency-key']; const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; const result = await launchService.createLaunchWithIdempotency({ diff --git a/src/app/routes/ready.get.ts b/src/app/routes/ready.get.ts index 51c4bed..894224d 100644 --- a/src/app/routes/ready.get.ts +++ b/src/app/routes/ready.get.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import type { ChainRegistry } from '../../infra/chain/registry'; +import type { SolanaLaunchService } from '../../modules/launches/solana'; const READY_CHECK_FAILURE_MESSAGE = 'dependency unavailable'; @@ -19,10 +20,11 @@ const withTimeout = async (promise: Promise, timeoutMs: number): Promise, chainRegistry: ChainRegistry, + solanaLaunchService: SolanaLaunchService, timeoutMs: number, ) => { fastify.get('/ready', async (request, reply) => { - const checks = await Promise.all( + const chainChecks = await Promise.all( chainRegistry.list().map(async (chain) => { try { const block = await withTimeout(chain.publicClient.getBlockNumber(), timeoutMs); @@ -41,14 +43,17 @@ export const registerReadyRoute = async ( }), ); - const ok = checks.every((check) => check.ok); + const solana = await solanaLaunchService.getReadiness(); + + const ok = chainChecks.every((check) => check.ok) && solana.ok; if (!ok) { reply.status(503); } return { status: ok ? 'ready' : 'degraded', - checks, + checks: chainChecks, + solana, }; }); }; diff --git a/src/app/routes/solana-launches.post.ts b/src/app/routes/solana-launches.post.ts new file mode 100644 index 0000000..bdecfd9 --- /dev/null +++ b/src/app/routes/solana-launches.post.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from 'fastify'; + +import type { SolanaNetwork } from '../../core/types'; +import { + parseDedicatedSolanaCreateLaunchRequest, +} from '../../modules/launches/solana'; +import type { LaunchService } from '../../modules/launches/service'; + +export const registerCreateSolanaLaunchRoute = async ( + fastify: FastifyInstance, + launchService: LaunchService, + defaultNetwork: SolanaNetwork, +) => { + fastify.post('/v1/solana/launches', async (request, reply) => { + const payload = parseDedicatedSolanaCreateLaunchRequest(request.body, defaultNetwork); + const rawKey = request.headers['idempotency-key']; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + const result = await launchService.createLaunchWithIdempotency({ + input: payload, + idempotencyKey, + }); + if (result.replayed) { + reply.header('x-idempotency-replayed', 'true'); + } + return result.response; + }); +}; diff --git a/src/app/server.ts b/src/app/server.ts index 54e5590..20fef17 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -18,11 +18,13 @@ import { createIdempotencyStore, type IdempotencyStore } from '../infra/idempote import { TxSubmitter } from '../infra/tx/submitter'; import { PricingService } from '../modules/pricing/service'; import { LaunchService } from '../modules/launches/service'; +import { SolanaLaunchService } from '../modules/launches/solana'; import { StatusService } from '../modules/status/service'; import { registerCreateLaunchRoute } from './routes/launches.post'; import { registerCreateMulticurveAliasRoute } from './routes/launches-multicurve.post'; import { registerCreateStaticAliasRoute } from './routes/launches-static.post'; import { registerCreateDynamicAliasRoute } from './routes/launches-dynamic.post'; +import { registerCreateSolanaLaunchRoute } from './routes/solana-launches.post'; import { registerLaunchStatusRoute } from './routes/launches-status.get'; import { registerHealthRoute } from './routes/health.get'; import { registerReadyRoute } from './routes/ready.get'; @@ -36,6 +38,7 @@ export interface AppServices { sdkRegistry: DopplerSdkRegistry; redisClient?: Redis; pricingService: PricingService; + solanaLaunchService: SolanaLaunchService; launchService: LaunchService; statusService: StatusService; txSubmitter: TxSubmitter; @@ -98,6 +101,7 @@ export const buildServices = (config: AppConfig): AppServices => { redisLockRefreshMs: config.idempotency.redisLockRefreshMs, }); const pricingService = new PricingService(config); + const solanaLaunchService = new SolanaLaunchService({ config, pricingService }); const launchService = new LaunchService({ chainRegistry, sdkRegistry, @@ -105,6 +109,7 @@ export const buildServices = (config: AppConfig): AppServices => { txSubmitter, idempotencyStore, requireIdempotencyKey: config.idempotency.requireKey, + solanaLaunchService, }); const statusService = new StatusService({ chainRegistry, sdkRegistry }); @@ -115,6 +120,7 @@ export const buildServices = (config: AppConfig): AppServices => { sdkRegistry, redisClient, pricingService, + solanaLaunchService, launchService, statusService, txSubmitter, @@ -196,15 +202,22 @@ export const buildServer = async (services?: AppServices) => { await registerReadyRoute( app, resolvedServices.chainRegistry, + resolvedServices.solanaLaunchService, resolvedServices.config.readyRpcTimeoutMs, ); await registerCapabilitiesRoute( app, + resolvedServices.config, resolvedServices.chainRegistry, resolvedServices.pricingService, ); await registerMetricsRoute(app, resolvedServices.metrics); await registerCreateLaunchRoute(app, resolvedServices.launchService); + await registerCreateSolanaLaunchRoute( + app, + resolvedServices.launchService, + resolvedServices.config.solana.defaultNetwork, + ); await registerCreateMulticurveAliasRoute(app, resolvedServices.launchService); await registerCreateStaticAliasRoute(app, resolvedServices.launchService); await registerCreateDynamicAliasRoute(app, resolvedServices.launchService); diff --git a/src/core/config.ts b/src/core/config.ts index 6853a2d..b347c46 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,7 +1,7 @@ import 'dotenv/config'; import { AppError } from './errors'; -import type { AuctionType, GovernanceMode, MigrationType } from './types'; +import type { AuctionType, GovernanceMode, MigrationType, SolanaNetwork } from './types'; import { dopplerTemplateConfig } from '../../doppler.config'; import type { DeploymentMode, @@ -22,6 +22,22 @@ export interface ChainRuntimeConfig { governanceEnabled: boolean; } +export interface SolanaRuntimeConfig { + enabled: boolean; + defaultNetwork: SolanaNetwork; + devnetRpcUrl: string; + devnetWsUrl: string; + mainnetBetaRpcUrl?: string; + mainnetBetaWsUrl?: string; + keypairBytes?: Uint8Array; + confirmTimeoutMs: number; + useAlt: boolean; + altAddress?: string; + priceMode: 'required' | 'fixed' | 'coingecko'; + fixedNumerairePriceUsd?: number; + coingeckoAssetId: string; +} + export interface AppConfig { port: number; deploymentMode: DeploymentMode; @@ -59,6 +75,7 @@ export interface AppConfig { coingeckoAssetId: string; apiKey?: string; }; + solana: SolanaRuntimeConfig; } const parseBoolean = (value: string | undefined, fallback: boolean): boolean => { @@ -75,6 +92,19 @@ const parseNumber = (value: string | undefined, fallback: number): number => { return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; }; +const parseOptionalPositiveNumber = (value: string | undefined): number | undefined => { + if (value === undefined || value.trim() === '') { + return undefined; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new AppError(500, 'INVALID_ENV', `Expected a positive number but received ${value}`); + } + + return parsed; +}; + const parseStringArray = (value: string | undefined): string[] => { if (!value || value.trim() === '') return []; return value @@ -136,6 +166,79 @@ const parseStringOrFallback = (value: string | undefined, fallback: string): str return trimmed === '' ? fallback : trimmed; }; +const parseSolanaDefaultNetwork = ( + value: string | undefined, + fallback: SolanaNetwork, +): SolanaNetwork => { + const raw = value?.trim(); + if (!raw) { + return fallback; + } + + if (raw === 'solanaDevnet' || raw === 'solanaMainnetBeta') { + return raw; + } + + throw new AppError( + 500, + 'INVALID_ENV', + 'SOLANA_DEFAULT_NETWORK must be "solanaDevnet" or "solanaMainnetBeta"', + ); +}; + +const parseSolanaPriceMode = ( + value: string | undefined, +): 'required' | 'fixed' | 'coingecko' => { + const raw = value?.trim().toLowerCase() || 'required'; + if (raw === 'required' || raw === 'fixed' || raw === 'coingecko') { + return raw; + } + + throw new AppError( + 500, + 'INVALID_ENV', + 'SOLANA_PRICE_MODE must be "required", "fixed", or "coingecko"', + ); +}; + +const parseSolanaKeypairBytes = (value: string | undefined): Uint8Array | undefined => { + if (value === undefined || value.trim() === '') { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new AppError( + 500, + 'INVALID_ENV', + 'SOLANA_KEYPAIR must be a JSON array containing 64 secret-key bytes', + ); + } + + if (!Array.isArray(parsed) || parsed.length !== 64) { + throw new AppError( + 500, + 'INVALID_ENV', + 'SOLANA_KEYPAIR must be a JSON array containing 64 secret-key bytes', + ); + } + + const bytes = parsed.map((entry) => { + if (!Number.isInteger(entry) || entry < 0 || entry > 255) { + throw new AppError( + 500, + 'INVALID_ENV', + 'SOLANA_KEYPAIR must contain only byte values between 0 and 255', + ); + } + return entry; + }); + + return Uint8Array.from(bytes); +}; + const parseOptionalStringArray = (value: string | undefined): string[] | undefined => { if (value === undefined) { return undefined; @@ -285,6 +388,49 @@ export const loadConfig = (): AppConfig => { } } + const solanaEnabled = parseBoolean(process.env.SOLANA_ENABLED, false); + const solanaDefaultNetwork = parseSolanaDefaultNetwork( + process.env.SOLANA_DEFAULT_NETWORK, + 'solanaDevnet', + ); + const solanaPriceMode = parseSolanaPriceMode(process.env.SOLANA_PRICE_MODE); + const solanaKeypairBytes = parseSolanaKeypairBytes(process.env.SOLANA_KEYPAIR); + const solanaFixedNumerairePriceUsd = + solanaPriceMode === 'required' + ? undefined + : parseOptionalPositiveNumber(process.env.SOLANA_FIXED_NUMERAIRE_PRICE_USD); + const solanaAltAddress = process.env.SOLANA_DEVNET_ALT_ADDRESS?.trim() || undefined; + + if (solanaEnabled) { + if (!solanaKeypairBytes) { + throw new AppError(500, 'MISSING_ENV', 'SOLANA_KEYPAIR is required when SOLANA_ENABLED=true'); + } + + if (!process.env.SOLANA_DEVNET_RPC_URL?.trim()) { + throw new AppError( + 500, + 'MISSING_ENV', + 'SOLANA_DEVNET_RPC_URL is required when SOLANA_ENABLED=true', + ); + } + + if (!process.env.SOLANA_DEVNET_WS_URL?.trim()) { + throw new AppError( + 500, + 'MISSING_ENV', + 'SOLANA_DEVNET_WS_URL is required when SOLANA_ENABLED=true', + ); + } + + if (solanaPriceMode === 'fixed' && solanaFixedNumerairePriceUsd === undefined) { + throw new AppError( + 500, + 'MISSING_ENV', + 'SOLANA_FIXED_NUMERAIRE_PRICE_USD is required when SOLANA_PRICE_MODE=fixed', + ); + } + } + return { port: parseNumber(process.env.PORT, template.port), deploymentMode, @@ -328,5 +474,26 @@ export const loadConfig = (): AppConfig => { ), apiKey: process.env.PRICE_API_KEY, }, + solana: { + enabled: solanaEnabled, + defaultNetwork: solanaDefaultNetwork, + devnetRpcUrl: parseStringOrFallback( + process.env.SOLANA_DEVNET_RPC_URL, + 'https://api.devnet.solana.com', + ), + devnetWsUrl: parseStringOrFallback( + process.env.SOLANA_DEVNET_WS_URL, + 'wss://api.devnet.solana.com', + ), + mainnetBetaRpcUrl: process.env.SOLANA_MAINNET_BETA_RPC_URL?.trim() || undefined, + mainnetBetaWsUrl: process.env.SOLANA_MAINNET_BETA_WS_URL?.trim() || undefined, + keypairBytes: solanaKeypairBytes, + confirmTimeoutMs: parseInteger(process.env.SOLANA_CONFIRM_TIMEOUT_MS, 60_000), + useAlt: parseBoolean(process.env.SOLANA_DEVNET_USE_ALT, true), + altAddress: solanaAltAddress, + priceMode: solanaPriceMode, + fixedNumerairePriceUsd: solanaFixedNumerairePriceUsd, + coingeckoAssetId: parseStringOrFallback(process.env.SOLANA_COINGECKO_ASSET_ID, 'solana'), + }, }; }; diff --git a/src/core/types.ts b/src/core/types.ts index 9dd479c..5171e60 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,5 +1,6 @@ export type HexAddress = `0x${string}`; export type HexHash = `0x${string}`; +export type SolanaNetwork = 'solanaDevnet' | 'solanaMainnetBeta'; export type GovernanceMode = 'noOp' | 'default' | 'custom'; export type MigrationType = 'noOp' | 'uniswapV2' | 'uniswapV3' | 'uniswapV4'; @@ -218,6 +219,38 @@ export interface CreateLaunchResponse { effectiveConfig: EffectiveLaunchConfig; } +export interface CreateSolanaLaunchPredicted { + tokenAddress: string; + launchAuthorityAddress: string; + baseVaultAddress: string; + quoteVaultAddress: string; +} + +export interface SolanaEffectiveLaunchConfig { + tokensForSale: string; + allocationAmount: string; + allocationLockMode: 'none'; + numeraireAddress: string; + numerairePriceUsd: number; + curveVirtualBase: string; + curveVirtualQuote: string; + curveFeeBps: number; + allowBuy: boolean; + allowSell: boolean; + tokenDecimals: number; +} + +export interface CreateSolanaLaunchResponse { + launchId: string; + network: SolanaNetwork; + signature: string; + explorerUrl: string; + predicted: CreateSolanaLaunchPredicted; + effectiveConfig: SolanaEffectiveLaunchConfig; +} + +export type CreateAnyLaunchResponse = CreateLaunchResponse | CreateSolanaLaunchResponse; + export type LaunchStatus = 'pending' | 'confirmed' | 'reverted' | 'not_found'; export interface LaunchResult { @@ -258,4 +291,13 @@ export interface CapabilitiesResponse { provider: string; }; chains: ChainCapability[]; + solana?: { + enabled: boolean; + supportedNetworks: SolanaNetwork[]; + unsupportedNetworks: SolanaNetwork[]; + dedicatedRouteInputAliases: Array<'devnet' | 'mainnet-beta'>; + creationOnly: true; + numeraireAddress: string; + priceResolutionModes: Array<'request' | 'fixed' | 'coingecko'>; + }; } diff --git a/src/infra/chain/receipt-decoder.ts b/src/infra/chain/receipt-decoder.ts index 67bc2d4..9cc05fc 100644 --- a/src/infra/chain/receipt-decoder.ts +++ b/src/infra/chain/receipt-decoder.ts @@ -1,4 +1,4 @@ -import { airlockAbi } from '@whetstone-research/doppler-sdk'; +import { airlockAbi } from '@whetstone-research/doppler-sdk/evm'; import { decodeEventLog, type Hex } from 'viem'; import type { HexAddress } from '../../core/types'; diff --git a/src/infra/chain/registry.ts b/src/infra/chain/registry.ts index d0049a3..dd51da8 100644 --- a/src/infra/chain/registry.ts +++ b/src/infra/chain/registry.ts @@ -1,4 +1,4 @@ -import { getAddresses } from '@whetstone-research/doppler-sdk'; +import { getAddresses } from '@whetstone-research/doppler-sdk/evm'; import { defineChain, http, createPublicClient, createWalletClient } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; diff --git a/src/infra/doppler/sdk-client.ts b/src/infra/doppler/sdk-client.ts index 97b39cc..00987a7 100644 --- a/src/infra/doppler/sdk-client.ts +++ b/src/infra/doppler/sdk-client.ts @@ -1,4 +1,4 @@ -import { DopplerSDK } from '@whetstone-research/doppler-sdk'; +import { DopplerSDK } from '@whetstone-research/doppler-sdk/evm'; import type { ChainContext } from '../chain/registry'; diff --git a/src/infra/idempotency/store.ts b/src/infra/idempotency/store.ts index 0d398cc..8af5d82 100644 --- a/src/infra/idempotency/store.ts +++ b/src/infra/idempotency/store.ts @@ -4,15 +4,28 @@ import { dirname } from 'node:path'; import type { IdempotencyBackend } from '../../core/config'; import { AppError } from '../../core/errors'; -import type { CreateLaunchResponse } from '../../core/types'; -import type { CreateLaunchRequestInput } from '../../modules/launches/schema'; +import type { CreateAnyLaunchResponse } from '../../core/types'; -interface IdempotencyRecord { +interface CompletedIdempotencyRecord { + state: 'completed'; + payloadHash: string; + response: CreateAnyLaunchResponse; + createdAtMs: number; +} + +interface InDoubtIdempotencyRecord { + state: 'in_doubt'; payloadHash: string; - response: CreateLaunchResponse; + error: { + code: string; + message: string; + details?: unknown; + }; createdAtMs: number; } +type IdempotencyRecord = CompletedIdempotencyRecord | InDoubtIdempotencyRecord; + interface RedisInProgressRecord { state: 'in_progress'; payloadHash: string; @@ -22,11 +35,22 @@ interface RedisInProgressRecord { interface RedisCompletedRecord { state: 'completed'; payloadHash: string; - response: CreateLaunchResponse; + response: CreateAnyLaunchResponse; + createdAtMs: number; +} + +interface RedisInDoubtRecord { + state: 'in_doubt'; + payloadHash: string; + error: { + code: string; + message: string; + details?: unknown; + }; createdAtMs: number; } -type RedisIdempotencyRecord = RedisInProgressRecord | RedisCompletedRecord; +type RedisIdempotencyRecord = RedisInProgressRecord | RedisCompletedRecord | RedisInDoubtRecord; interface PersistedStore { records: Record; @@ -35,9 +59,9 @@ interface PersistedStore { export interface IdempotencyStore { execute( key: string, - payload: CreateLaunchRequestInput, - action: () => Promise, - ): Promise<{ response: CreateLaunchResponse; replayed: boolean }>; + payload: unknown, + action: () => Promise, + ): Promise<{ response: CreateAnyLaunchResponse; replayed: boolean }>; } export interface IdempotencyRedisClient { @@ -83,7 +107,7 @@ const stableStringify = (value: unknown): string => { return `{${body}}`; }; -const hashPayload = (payload: CreateLaunchRequestInput): string => +const hashPayload = (payload: unknown): string => createHash('sha256').update(stableStringify(payload)).digest('hex'); const delay = async (ms: number): Promise => @@ -103,11 +127,14 @@ const throwInDoubtError = (): never => { ); }; +const isSolanaInDoubtError = (error: unknown): error is AppError => + error instanceof AppError && error.code === 'SOLANA_LAUNCH_IN_DOUBT'; + export class FileIdempotencyStore implements IdempotencyStore { private readonly records = new Map(); private readonly inFlight = new Map< string, - { payloadHash: string; promise: Promise } + { payloadHash: string; promise: Promise } >(); private readonly ttlMs: number; private readonly path: string; @@ -127,7 +154,28 @@ export class FileIdempotencyStore implements IdempotencyStore { const raw = readFileSync(this.path, 'utf8'); const parsed = JSON.parse(raw) as PersistedStore; for (const [key, record] of Object.entries(parsed.records ?? {})) { - this.records.set(key, record); + if ( + record && + typeof record === 'object' && + 'payloadHash' in record && + typeof record.payloadHash === 'string' && + 'createdAtMs' in record && + typeof record.createdAtMs === 'number' + ) { + if ('state' in record && record.state === 'in_doubt' && 'error' in record) { + this.records.set(key, record); + continue; + } + + if ('response' in record) { + this.records.set(key, { + state: 'completed', + payloadHash: record.payloadHash, + response: record.response as CreateAnyLaunchResponse, + createdAtMs: record.createdAtMs, + }); + } + } } this.pruneExpired(); } catch { @@ -158,9 +206,9 @@ export class FileIdempotencyStore implements IdempotencyStore { async execute( key: string, - payload: CreateLaunchRequestInput, - action: () => Promise, - ): Promise<{ response: CreateLaunchResponse; replayed: boolean }> { + payload: unknown, + action: () => Promise, + ): Promise<{ response: CreateAnyLaunchResponse; replayed: boolean }> { if (!this.enabled) { return { response: await action(), replayed: false }; } @@ -173,6 +221,9 @@ export class FileIdempotencyStore implements IdempotencyStore { if (existing.payloadHash !== payloadHash) { throwKeyReuseMismatch('Idempotency key was already used with a different request payload'); } + if (existing.state === 'in_doubt') { + throw new AppError(409, existing.error.code, existing.error.message, existing.error.details); + } return { response: existing.response, replayed: true }; } @@ -192,12 +243,29 @@ export class FileIdempotencyStore implements IdempotencyStore { try { const response = await promise; this.records.set(key, { + state: 'completed', payloadHash, response, createdAtMs: Date.now(), }); this.persist(); return { response, replayed: false }; + } catch (error) { + if (isSolanaInDoubtError(error)) { + this.records.set(key, { + state: 'in_doubt', + payloadHash, + error: { + code: error.code, + message: error.message, + details: error.details, + }, + createdAtMs: Date.now(), + }); + this.persist(); + throw error; + } + throw error; } finally { this.inFlight.delete(key); } @@ -207,7 +275,7 @@ export class FileIdempotencyStore implements IdempotencyStore { export class RedisIdempotencyStore implements IdempotencyStore { private readonly inFlight = new Map< string, - { payloadHash: string; promise: Promise } + { payloadHash: string; promise: Promise } >(); private readonly enabled: boolean; private readonly ttlMs: number; @@ -265,6 +333,7 @@ export class RedisIdempotencyStore implements IdempotencyStore { payloadHash?: unknown; createdAtMs?: unknown; response?: unknown; + error?: unknown; }; if (typeof parsed.payloadHash !== 'string' || typeof parsed.createdAtMs !== 'number') { return null; @@ -282,7 +351,26 @@ export class RedisIdempotencyStore implements IdempotencyStore { return { state: 'completed', payloadHash: parsed.payloadHash, - response: parsed.response as CreateLaunchResponse, + response: parsed.response as CreateAnyLaunchResponse, + createdAtMs: parsed.createdAtMs, + }; + } + + if ( + parsed.state === 'in_doubt' && + typeof parsed.error === 'object' && + parsed.error !== null && + typeof (parsed.error as { code?: unknown }).code === 'string' && + typeof (parsed.error as { message?: unknown }).message === 'string' + ) { + return { + state: 'in_doubt', + payloadHash: parsed.payloadHash, + error: { + code: (parsed.error as { code: string }).code, + message: (parsed.error as { message: string }).message, + details: (parsed.error as { details?: unknown }).details, + }, createdAtMs: parsed.createdAtMs, }; } @@ -292,7 +380,7 @@ export class RedisIdempotencyStore implements IdempotencyStore { return { state: 'completed', payloadHash: parsed.payloadHash, - response: parsed.response as CreateLaunchResponse, + response: parsed.response as CreateAnyLaunchResponse, createdAtMs: parsed.createdAtMs, }; } @@ -315,7 +403,7 @@ export class RedisIdempotencyStore implements IdempotencyStore { private async writeCompletedRecord( key: string, payloadHash: string, - response: CreateLaunchResponse, + response: CreateAnyLaunchResponse, ): Promise { const record: RedisCompletedRecord = { state: 'completed', @@ -327,6 +415,25 @@ export class RedisIdempotencyStore implements IdempotencyStore { await this.redis.set(this.recordKey(key), JSON.stringify(record), 'PX', this.ttlMs); } + private async writeInDoubtRecord( + key: string, + payloadHash: string, + error: AppError, + ): Promise { + const record: RedisInDoubtRecord = { + state: 'in_doubt', + payloadHash, + error: { + code: error.code, + message: error.message, + details: error.details, + }, + createdAtMs: Date.now(), + }; + + await this.redis.set(this.recordKey(key), JSON.stringify(record), 'PX', this.ttlMs); + } + private async clearRecord(key: string): Promise { await this.redis.del(this.recordKey(key)); } @@ -355,7 +462,7 @@ export class RedisIdempotencyStore implements IdempotencyStore { private async waitForRecordOrUnlock( key: string, payloadHash: string, - ): Promise { + ): Promise { const lockKey = this.lockKey(key); for (;;) { @@ -369,6 +476,9 @@ export class RedisIdempotencyStore implements IdempotencyStore { if (existing.state === 'completed') { return existing; } + if (existing.state === 'in_doubt') { + return existing; + } } const lockValue = await this.redis.get(lockKey); @@ -393,8 +503,8 @@ export class RedisIdempotencyStore implements IdempotencyStore { key: string, payloadHash: string, lockValue: string, - action: () => Promise, - ): Promise<{ response: CreateLaunchResponse; replayed: boolean }> { + action: () => Promise, + ): Promise<{ response: CreateAnyLaunchResponse; replayed: boolean }> { let heartbeatTimer: NodeJS.Timeout | undefined; let actionCompleted = false; const stopHeartbeat = () => { @@ -429,7 +539,9 @@ export class RedisIdempotencyStore implements IdempotencyStore { await this.writeCompletedRecord(key, payloadHash, response); return { response, replayed: false }; } catch (error) { - if (!actionCompleted) { + if (isSolanaInDoubtError(error)) { + await this.writeInDoubtRecord(key, payloadHash, error); + } else if (!actionCompleted) { try { await this.clearRecord(key); } catch { @@ -446,9 +558,9 @@ export class RedisIdempotencyStore implements IdempotencyStore { async execute( key: string, - payload: CreateLaunchRequestInput, - action: () => Promise, - ): Promise<{ response: CreateLaunchResponse; replayed: boolean }> { + payload: unknown, + action: () => Promise, + ): Promise<{ response: CreateAnyLaunchResponse; replayed: boolean }> { if (!this.enabled) { return { response: await action(), replayed: false }; } @@ -468,8 +580,20 @@ export class RedisIdempotencyStore implements IdempotencyStore { return { response: existing.response, replayed: true }; } + if (existing.state === 'in_doubt') { + throw new AppError(409, existing.error.code, existing.error.message, existing.error.details); + } + const replay = await this.waitForRecordOrUnlock(key, payloadHash); if (replay) { + if (replay.state === 'in_doubt') { + throw new AppError( + 409, + replay.error.code, + replay.error.message, + replay.error.details, + ); + } return { response: replay.response, replayed: true }; } @@ -499,6 +623,14 @@ export class RedisIdempotencyStore implements IdempotencyStore { const replay = await this.waitForRecordOrUnlock(key, payloadHash); if (replay) { + if (replay.state === 'in_doubt') { + throw new AppError( + 409, + replay.error.code, + replay.error.message, + replay.error.details, + ); + } return { response: replay.response, replayed: true }; } } diff --git a/src/modules/auctions/dynamic/service.ts b/src/modules/auctions/dynamic/service.ts index 2c95eba..e0b222b 100644 --- a/src/modules/auctions/dynamic/service.ts +++ b/src/modules/auctions/dynamic/service.ts @@ -1,4 +1,4 @@ -import { airlockAbi, type MigrationConfig } from '@whetstone-research/doppler-sdk'; +import { airlockAbi, type MigrationConfig } from '@whetstone-research/doppler-sdk/evm'; import { parseUnits } from 'viem'; import { AppError } from '../../../core/errors'; diff --git a/src/modules/auctions/multicurve/mapper.ts b/src/modules/auctions/multicurve/mapper.ts index cb7648a..feb0e0c 100644 --- a/src/modules/auctions/multicurve/mapper.ts +++ b/src/modules/auctions/multicurve/mapper.ts @@ -1,4 +1,4 @@ -import { WAD, type BeneficiaryData } from '@whetstone-research/doppler-sdk'; +import { WAD, type BeneficiaryData } from '@whetstone-research/doppler-sdk/evm'; import { AppError } from '../../../core/errors'; import type { CreateLaunchRequestInput } from '../../launches/schema'; diff --git a/src/modules/auctions/multicurve/service.ts b/src/modules/auctions/multicurve/service.ts index 23849d2..6b60ce7 100644 --- a/src/modules/auctions/multicurve/service.ts +++ b/src/modules/auctions/multicurve/service.ts @@ -3,7 +3,7 @@ import { type MigrationConfig, type GovernanceOption, type BeneficiaryData, -} from '@whetstone-research/doppler-sdk'; +} from '@whetstone-research/doppler-sdk/evm'; import { AppError } from '../../../core/errors'; import type { CreateLaunchResponse, HexAddress, HexHash } from '../../../core/types'; diff --git a/src/modules/auctions/multicurve/tick-spacing.ts b/src/modules/auctions/multicurve/tick-spacing.ts index e45baa9..3657868 100644 --- a/src/modules/auctions/multicurve/tick-spacing.ts +++ b/src/modules/auctions/multicurve/tick-spacing.ts @@ -2,7 +2,7 @@ import { DEFAULT_MULTICURVE_LOWER_TICKS, DEFAULT_MULTICURVE_UPPER_TICKS, TICK_SPACINGS, -} from '@whetstone-research/doppler-sdk'; +} from '@whetstone-research/doppler-sdk/evm'; const PRESET_INDEX: Record<'low' | 'medium' | 'high', number> = { low: 0, diff --git a/src/modules/auctions/static/service.ts b/src/modules/auctions/static/service.ts index 147413b..e149a55 100644 --- a/src/modules/auctions/static/service.ts +++ b/src/modules/auctions/static/service.ts @@ -1,4 +1,4 @@ -import { airlockAbi, type BeneficiaryData } from '@whetstone-research/doppler-sdk'; +import { airlockAbi, type BeneficiaryData } from '@whetstone-research/doppler-sdk/evm'; import { AppError } from '../../../core/errors'; import type { diff --git a/src/modules/launches/service.ts b/src/modules/launches/service.ts index d7ce57e..4151e6d 100644 --- a/src/modules/launches/service.ts +++ b/src/modules/launches/service.ts @@ -1,5 +1,5 @@ import { AppError } from '../../core/errors'; -import type { CreateLaunchResponse } from '../../core/types'; +import type { CreateAnyLaunchResponse, CreateLaunchResponse } from '../../core/types'; import type { ChainRegistry } from '../../infra/chain/registry'; import type { DopplerSdkRegistry } from '../../infra/doppler/sdk-client'; import type { IdempotencyStore } from '../../infra/idempotency/store'; @@ -15,6 +15,10 @@ import type { import { createDynamicLaunch } from '../auctions/dynamic/service'; import { createMulticurveLaunch } from '../auctions/multicurve/service'; import { createStaticLaunch } from '../auctions/static/service'; +import { + SolanaLaunchService, + type CreateSolanaLaunchRequestInput, +} from './solana'; interface LaunchServiceDeps { chainRegistry: ChainRegistry; @@ -23,6 +27,7 @@ interface LaunchServiceDeps { txSubmitter: TxSubmitter; idempotencyStore: IdempotencyStore; requireIdempotencyKey: boolean; + solanaLaunchService: SolanaLaunchService; } export class LaunchService { @@ -32,6 +37,7 @@ export class LaunchService { private readonly txSubmitter: TxSubmitter; private readonly idempotencyStore: IdempotencyStore; private readonly requireIdempotencyKey: boolean; + private readonly solanaLaunchService: SolanaLaunchService; constructor(deps: LaunchServiceDeps) { this.chainRegistry = deps.chainRegistry; @@ -40,11 +46,17 @@ export class LaunchService { this.txSubmitter = deps.txSubmitter; this.idempotencyStore = deps.idempotencyStore; this.requireIdempotencyKey = deps.requireIdempotencyKey; + this.solanaLaunchService = deps.solanaLaunchService; } private async createLaunchInternal( - input: CreateLaunchRequestInput, - ): Promise { + input: CreateLaunchRequestInput | CreateSolanaLaunchRequestInput, + idempotencyKey?: string, + ): Promise { + if ('network' in input) { + return this.solanaLaunchService.createLaunch(input, idempotencyKey); + } + const chain = this.chainRegistry.get(input.chainId); ensureAuctionSupported(input.auction.type, chain.config); @@ -87,13 +99,13 @@ export class LaunchService { } async createLaunch(input: CreateLaunchRequestInput): Promise { - return this.createLaunchInternal(input); + return this.createLaunchInternal(input) as Promise; } async createLaunchWithIdempotency(args: { - input: CreateLaunchRequestInput; + input: CreateLaunchRequestInput | CreateSolanaLaunchRequestInput; idempotencyKey?: string; - }): Promise<{ response: CreateLaunchResponse; replayed: boolean }> { + }): Promise<{ response: CreateAnyLaunchResponse; replayed: boolean }> { const key = args.idempotencyKey?.trim(); if (this.requireIdempotencyKey && !key) { throw new AppError( @@ -109,7 +121,7 @@ export class LaunchService { } return this.idempotencyStore.execute(key, args.input, () => - this.createLaunchInternal(args.input), + this.createLaunchInternal(args.input, key), ); } } diff --git a/src/modules/launches/solana.ts b/src/modules/launches/solana.ts new file mode 100644 index 0000000..7c06670 --- /dev/null +++ b/src/modules/launches/solana.ts @@ -0,0 +1,804 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { + type Address, + address, + appendTransactionMessageInstructions, + assertAccountExists, + createKeyPairSignerFromBytes, + createSolanaRpc, + createTransactionMessage, + decodeAccount, + fetchAddressesForLookupTables, + fetchEncodedAccount, + generateKeyPairSigner, + getBase58Decoder, + getBase64EncodedWireTransaction, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signature, + signTransactionMessageWithSigners, +} from '@solana/kit'; +import { cpmm, initializer } from '@whetstone-research/doppler-sdk/solana'; +import { z } from 'zod'; + +import type { AppConfig } from '../../core/config'; +import { AppError } from '../../core/errors'; +import type { + CreateSolanaLaunchResponse, + SolanaNetwork, +} from '../../core/types'; +import type { PricingService } from '../pricing/service'; + +const U64_MAX = 18_446_744_073_709_551_615n; +const SOLANA_TOKEN_DECIMALS = 9; +const SOLANA_NUMERAIRE_DECIMALS = 9; +const SOLANA_CONFIRM_POLL_INTERVAL_MS = 500; +const SOLANA_WSOL_MINT_ADDRESS = address('So11111111111111111111111111111111111111112'); +const SOLANA_SYSTEM_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); +const SOLANA_RENT_SYSVAR_ADDRESS = address('SysvarRent111111111111111111111111111111111'); + +const strictObject = (shape: T) => z.object(shape).strict(); + +const positiveFiniteNumberSchema = z + .number() + .refine((value) => Number.isFinite(value) && value > 0, 'must be a positive number'); + +const solanaAddressSchema = z + .string() + .refine((value) => { + try { + address(value); + return true; + } catch { + return false; + } + }, 'must be a valid Solana address'); + +const u64StringSchema = z + .string() + .regex(/^\d+$/, 'must be a positive integer string') + .refine((value) => { + const parsed = BigInt(value); + return parsed > 0n && parsed <= U64_MAX; + }, 'must be a positive u64 integer string'); + +const canonicalSolanaNetworkSchema = z.enum(['solanaDevnet', 'solanaMainnetBeta']); +const dedicatedSolanaNetworkSchema = z.enum(['devnet', 'mainnet-beta']); + +const solanaTokenMetadataSchema = strictObject({ + name: z.string().min(1).max(32), + symbol: z.string().min(1).max(10), + tokenURI: z.string().min(1).max(200), +}); + +const solanaEconomicsSchema = strictObject({ + totalSupply: u64StringSchema, +}); + +const solanaPairingSchema = strictObject({ + numeraireAddress: solanaAddressSchema.optional(), +}); + +const solanaPricingSchema = strictObject({ + numerairePriceUsd: positiveFiniteNumberSchema.optional(), +}); + +const solanaMigrationSchema = strictObject({ + type: z.literal('noOp'), +}); + +const solanaAuctionSchema = strictObject({ + type: z.literal('xyk'), + curveConfig: strictObject({ + type: z.literal('range'), + marketCapStartUsd: positiveFiniteNumberSchema, + marketCapEndUsd: positiveFiniteNumberSchema, + }), + curveFeeBps: z.number().int().min(0).max(10_000).optional(), + allowBuy: z.boolean().optional(), + allowSell: z.boolean().optional(), +}); + +const baseSolanaCreateLaunchRequestShape = { + tokenMetadata: solanaTokenMetadataSchema, + economics: solanaEconomicsSchema, + pairing: solanaPairingSchema.optional(), + pricing: solanaPricingSchema.optional(), + governance: z.literal(false).optional(), + migration: solanaMigrationSchema.optional(), + auction: solanaAuctionSchema, +} satisfies z.ZodRawShape; + +export const dedicatedSolanaCreateLaunchRequestSchema = strictObject({ + network: dedicatedSolanaNetworkSchema.optional(), + ...baseSolanaCreateLaunchRequestShape, +}); + +export const genericSolanaCreateLaunchRequestSchema = strictObject({ + network: canonicalSolanaNetworkSchema, + ...baseSolanaCreateLaunchRequestShape, +}); + +export type DedicatedSolanaCreateLaunchRequestInput = z.infer< + typeof dedicatedSolanaCreateLaunchRequestSchema +>; +export type CreateSolanaLaunchRequestInput = z.infer; + +export interface SolanaReadinessCheck { + name: 'rpcReachable' | 'latestBlockhash' | 'initializerConfig' | 'addressLookupTable'; + ok: boolean; + error?: string; +} + +export interface SolanaReadinessResult { + enabled: boolean; + ok: boolean; + network?: 'solanaDevnet'; + checks: SolanaReadinessCheck[]; +} + +interface DerivedCurveConfig { + curveVirtualBase: bigint; + curveVirtualQuote: bigint; +} + +const toCanonicalSolanaNetwork = ( + network: z.infer, +): SolanaNetwork => { + if (network === 'devnet') { + return 'solanaDevnet'; + } + + return 'solanaMainnetBeta'; +}; + +export const isSolanaCanonicalNetwork = (value: unknown): value is SolanaNetwork => + value === 'solanaDevnet' || value === 'solanaMainnetBeta'; + +export const normalizeDedicatedSolanaCreateRequest = ( + input: DedicatedSolanaCreateLaunchRequestInput, + defaultNetwork: SolanaNetwork, +): CreateSolanaLaunchRequestInput => + genericSolanaCreateLaunchRequestSchema.parse({ + ...input, + network: input.network ? toCanonicalSolanaNetwork(input.network) : defaultNetwork, + }); + +const remapSolanaSchemaError = (error: z.ZodError): never => { + const metadataIssue = error.issues.find((issue) => issue.path[0] === 'tokenMetadata'); + if (metadataIssue) { + throw new AppError(422, 'SOLANA_INVALID_METADATA', 'Invalid Solana token metadata', { + issues: error.issues, + }); + } + + const curveIssue = error.issues.find( + (issue) => + issue.path[0] === 'auction' && + (issue.path[1] === 'type' || + issue.path[1] === 'curveConfig' || + issue.path[1] === 'curveFeeBps'), + ); + if (curveIssue) { + throw new AppError(422, 'SOLANA_INVALID_CURVE', 'Invalid Solana XYK curve configuration', { + issues: error.issues, + }); + } + + throw error; +}; + +export const parseDedicatedSolanaCreateLaunchRequest = ( + body: unknown, + defaultNetwork: SolanaNetwork, +): CreateSolanaLaunchRequestInput => { + try { + const parsed = dedicatedSolanaCreateLaunchRequestSchema.parse(body); + return normalizeDedicatedSolanaCreateRequest(parsed, defaultNetwork); + } catch (error) { + if (error instanceof z.ZodError) { + remapSolanaSchemaError(error); + } + + throw error; + } +}; + +export const parseGenericSolanaCreateLaunchRequest = ( + body: unknown, +): CreateSolanaLaunchRequestInput => { + try { + return genericSolanaCreateLaunchRequestSchema.parse(body); + } catch (error) { + if (error instanceof z.ZodError) { + remapSolanaSchemaError(error); + } + + throw error; + } +}; + +export const deriveSolanaLaunchSeed = ( + network: SolanaNetwork, + idempotencyKey?: string, +): Uint8Array => { + if (!idempotencyKey) { + return randomBytes(32); + } + + return createHash('sha256') + .update(`solana-launch:${network}:${idempotencyKey}`) + .digest(); +}; + +const buildExplorerUrl = (network: SolanaNetwork, signature: string): string => { + if (network === 'solanaDevnet') { + return `https://explorer.solana.com/tx/${signature}?cluster=devnet`; + } + + return `https://explorer.solana.com/tx/${signature}?cluster=mainnet-beta`; +}; + +const errorMessage = (error: unknown): string => { + if (error instanceof Error && error.message.trim() !== '') { + return error.message; + } + return 'dependency unavailable'; +}; + +const delay = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const relativeError = (actual: number, expected: number): number => + expected === 0 ? 0 : Math.abs(actual - expected) / expected; + +const resolveVirtualBaseForRange = ( + baseForCurve: bigint, + marketCapStartUsd: number, + marketCapEndUsd: number, +): bigint => { + const priceRatio = marketCapEndUsd / marketCapStartUsd; + const sqrtRatio = Math.sqrt(priceRatio); + if (!Number.isFinite(sqrtRatio) || sqrtRatio <= 1) { + throw new AppError( + 422, + 'SOLANA_INVALID_CURVE', + 'marketCapEndUsd must be greater than marketCapStartUsd', + ); + } + + const virtualBase = Math.floor(Number(baseForCurve) / (sqrtRatio - 1)); + if (!Number.isFinite(virtualBase) || virtualBase <= 0) { + throw new AppError( + 422, + 'SOLANA_INVALID_CURVE', + 'Unable to derive a valid Solana XYK curve from the requested market-cap range', + ); + } + + const asBigInt = BigInt(virtualBase); + if (asBigInt <= 0n || asBigInt > U64_MAX) { + throw new AppError( + 422, + 'SOLANA_INVALID_CURVE', + 'Derived Solana XYK reserves exceed the supported u64 range', + ); + } + + return asBigInt; +}; + +export const deriveSolanaCurveConfig = (args: { + totalSupply: bigint; + numerairePriceUsd: number; + marketCapStartUsd: number; + marketCapEndUsd: number; +}): DerivedCurveConfig => { + const baseForCurve = args.totalSupply; + const virtualBase = resolveVirtualBaseForRange( + baseForCurve, + args.marketCapStartUsd, + args.marketCapEndUsd, + ); + const { start } = cpmm.marketCapToCurveParams({ + startMarketCapUSD: args.marketCapStartUsd, + endMarketCapUSD: args.marketCapEndUsd, + baseTotalSupply: args.totalSupply, + baseForCurve, + baseDecimals: SOLANA_TOKEN_DECIMALS, + quoteDecimals: SOLANA_NUMERAIRE_DECIMALS, + numerairePriceUSD: args.numerairePriceUsd, + virtualBase, + }); + + if ( + start.curveVirtualBase <= 0n || + start.curveVirtualQuote <= 0n || + start.curveVirtualBase > U64_MAX || + start.curveVirtualQuote > U64_MAX + ) { + throw new AppError( + 422, + 'SOLANA_INVALID_CURVE', + 'Derived Solana XYK virtual reserves are out of bounds', + ); + } + + const baseReserveStart = baseForCurve; + const quoteReserveStart = 0n; + const quoteReserveEnd = (baseForCurve * start.curveVirtualQuote) / start.curveVirtualBase; + const derivedStartMarketCap = cpmm.curveParamsToMarketCap({ + curveVirtualBase: start.curveVirtualBase, + curveVirtualQuote: start.curveVirtualQuote, + baseReserve: baseReserveStart, + quoteReserve: quoteReserveStart, + baseTotalSupply: args.totalSupply, + baseDecimals: SOLANA_TOKEN_DECIMALS, + quoteDecimals: SOLANA_NUMERAIRE_DECIMALS, + numerairePriceUSD: args.numerairePriceUsd, + }); + const derivedEndMarketCap = cpmm.curveParamsToMarketCap({ + curveVirtualBase: start.curveVirtualBase, + curveVirtualQuote: start.curveVirtualQuote, + baseReserve: 0n, + quoteReserve: quoteReserveEnd, + baseTotalSupply: args.totalSupply, + baseDecimals: SOLANA_TOKEN_DECIMALS, + quoteDecimals: SOLANA_NUMERAIRE_DECIMALS, + numerairePriceUSD: args.numerairePriceUsd, + }); + + if ( + relativeError(derivedStartMarketCap, args.marketCapStartUsd) > 0.02 || + relativeError(derivedEndMarketCap, args.marketCapEndUsd) > 0.02 + ) { + throw new AppError( + 422, + 'SOLANA_INVALID_CURVE', + 'Unable to derive a stable Solana XYK curve for the requested market-cap range', + ); + } + + return { + curveVirtualBase: start.curveVirtualBase, + curveVirtualQuote: start.curveVirtualQuote, + }; +}; + +export class SolanaLaunchService { + private readonly config: AppConfig; + private readonly pricingService: PricingService; + private readonly rpc: ReturnType; + private payerSignerPromise?: Promise>>; + + constructor(args: { config: AppConfig; pricingService: PricingService }) { + this.config = args.config; + this.pricingService = args.pricingService; + this.rpc = createSolanaRpc(this.config.solana.devnetRpcUrl); + } + + private getAltAddress(): Address | undefined { + if (!this.config.solana.useAlt) { + return undefined; + } + + return this.config.solana.altAddress + ? address(this.config.solana.altAddress) + : initializer.DOPPLER_DEVNET_ALT; + } + + private async getPayerSigner() { + if (!this.config.solana.keypairBytes) { + throw new AppError(500, 'MISSING_ENV', 'SOLANA_KEYPAIR is required for Solana creation'); + } + + if (!this.payerSignerPromise) { + this.payerSignerPromise = createKeyPairSignerFromBytes(this.config.solana.keypairBytes); + } + + return this.payerSignerPromise; + } + + private assertEnabled(): void { + if (!this.config.solana.enabled) { + throw new AppError( + 501, + 'SOLANA_NETWORK_UNSUPPORTED', + 'Solana creation is not enabled on this server', + ); + } + } + + private assertSupportedNetwork(network: SolanaNetwork): void { + if (network === 'solanaMainnetBeta') { + throw new AppError( + 501, + 'SOLANA_NETWORK_UNSUPPORTED', + 'solanaMainnetBeta is scaffolded but not executable in this API profile', + ); + } + } + + private resolveNumeraireAddress(input: CreateSolanaLaunchRequestInput): Address { + const requested = input.pairing?.numeraireAddress; + if (!requested) { + return SOLANA_WSOL_MINT_ADDRESS; + } + + if (requested !== SOLANA_WSOL_MINT_ADDRESS) { + throw new AppError( + 422, + 'SOLANA_NUMERAIRE_UNSUPPORTED', + 'Solana launches currently support only the WSOL numeraire mint', + ); + } + + return address(requested); + } + + private async resolveNumerairePriceUsd( + input: CreateSolanaLaunchRequestInput, + numeraireAddress: Address, + ): Promise { + if (numeraireAddress !== SOLANA_WSOL_MINT_ADDRESS) { + throw new AppError( + 422, + 'SOLANA_NUMERAIRE_UNSUPPORTED', + 'Solana launches currently support only the WSOL numeraire mint', + ); + } + + const override = input.pricing?.numerairePriceUsd; + if (override !== undefined) { + if (!Number.isFinite(override) || override <= 0) { + throw new AppError( + 422, + 'SOLANA_NUMERAIRE_PRICE_REQUIRED', + 'pricing.numerairePriceUsd must be a positive number', + ); + } + return override; + } + + if (this.config.solana.fixedNumerairePriceUsd !== undefined) { + return this.config.solana.fixedNumerairePriceUsd; + } + + if (this.config.solana.priceMode === 'coingecko') { + return this.pricingService.getUsdPriceByAssetId(this.config.solana.coingeckoAssetId); + } + + throw new AppError( + 422, + 'SOLANA_NUMERAIRE_PRICE_REQUIRED', + 'WSOL/USD price resolution is unavailable; provide pricing.numerairePriceUsd in the request', + ); + } + + private async fetchInitializerConfigAddress(): Promise
{ + const [configAddress] = await initializer.getConfigAddress(); + const encodedAccount = await fetchEncodedAccount(this.rpc, configAddress, { + commitment: 'confirmed', + }); + const decodedAccount = decodeAccount(encodedAccount, initializer.getInitConfigDecoder()); + assertAccountExists(decodedAccount); + return configAddress; + } + + async getReadiness(): Promise { + if (!this.config.solana.enabled) { + return { enabled: false, ok: true, checks: [] }; + } + + const checks: SolanaReadinessCheck[] = []; + + try { + await this.rpc.getVersion().send(); + checks.push({ name: 'rpcReachable', ok: true }); + } catch (error) { + checks.push({ name: 'rpcReachable', ok: false, error: errorMessage(error) }); + } + + try { + await this.rpc.getLatestBlockhash({ commitment: 'confirmed' }).send(); + checks.push({ name: 'latestBlockhash', ok: true }); + } catch (error) { + checks.push({ name: 'latestBlockhash', ok: false, error: errorMessage(error) }); + } + + try { + await this.fetchInitializerConfigAddress(); + checks.push({ name: 'initializerConfig', ok: true }); + } catch (error) { + checks.push({ name: 'initializerConfig', ok: false, error: errorMessage(error) }); + } + + const altAddress = this.getAltAddress(); + if (altAddress) { + try { + await fetchAddressesForLookupTables([altAddress], this.rpc, { + commitment: 'confirmed', + }); + checks.push({ name: 'addressLookupTable', ok: true }); + } catch (error) { + checks.push({ name: 'addressLookupTable', ok: false, error: errorMessage(error) }); + } + } else { + checks.push({ name: 'addressLookupTable', ok: true }); + } + + return { + enabled: true, + network: 'solanaDevnet', + ok: checks.every((check) => check.ok), + checks, + }; + } + + async createLaunch( + input: CreateSolanaLaunchRequestInput, + idempotencyKey?: string, + ): Promise { + this.assertEnabled(); + this.assertSupportedNetwork(input.network); + + const readiness = await this.getReadiness(); + if (!readiness.ok) { + throw new AppError(503, 'SOLANA_NOT_READY', 'Solana devnet is not ready for launch creation'); + } + + const totalSupply = BigInt(input.economics.totalSupply); + const numeraireAddress = this.resolveNumeraireAddress(input); + const numerairePriceUsd = await this.resolveNumerairePriceUsd(input, numeraireAddress); + const curveFeeBps = input.auction.curveFeeBps ?? 0; + const allowBuy = input.auction.allowBuy ?? true; + const allowSell = input.auction.allowSell ?? true; + const curveConfig = deriveSolanaCurveConfig({ + totalSupply, + numerairePriceUsd, + marketCapStartUsd: input.auction.curveConfig.marketCapStartUsd, + marketCapEndUsd: input.auction.curveConfig.marketCapEndUsd, + }); + + const payer = await this.getPayerSigner(); + const launchSeed = deriveSolanaLaunchSeed(input.network, idempotencyKey); + const namespace = payer.address; + const [launchAddress] = await initializer.getLaunchAddress(namespace, launchSeed); + const [launchAuthorityAddress] = await initializer.getLaunchAuthorityAddress(launchAddress); + const configAddress = await this.fetchInitializerConfigAddress(); + const baseMint = await generateKeyPairSigner(); + const baseVault = await generateKeyPairSigner(); + const quoteVault = await generateKeyPairSigner(); + const metadataAddress = await initializer.getTokenMetadataAddress(baseMint.address); + const altAddress = this.getAltAddress(); + + const distinctAddresses = new Set([ + launchAddress, + launchAuthorityAddress, + configAddress, + baseMint.address, + baseVault.address, + quoteVault.address, + metadataAddress, + numeraireAddress, + ]); + if (distinctAddresses.size !== 8) { + throw new AppError( + 500, + 'SOLANA_SUBMISSION_FAILED', + 'Derived Solana launch addresses are internally inconsistent', + ); + } + + const instruction = await initializer.createInitializeLaunchInstruction( + { + config: configAddress, + launch: launchAddress, + launchAuthority: launchAuthorityAddress, + baseMint, + quoteMint: numeraireAddress, + baseVault, + quoteVault, + payer, + authority: payer, + // The initializer still expects a migrator program account even for no-op launches. + migratorProgram: SOLANA_SYSTEM_PROGRAM_ADDRESS, + rent: SOLANA_RENT_SYSVAR_ADDRESS, + metadataAccount: metadataAddress, + ...(altAddress ? { addressLookupTable: altAddress } : {}), + }, + { + namespace, + launchId: launchSeed, + baseDecimals: SOLANA_TOKEN_DECIMALS, + baseTotalSupply: totalSupply, + baseForDistribution: 0n, + baseForLiquidity: 0n, + curveVirtualBase: curveConfig.curveVirtualBase, + curveVirtualQuote: curveConfig.curveVirtualQuote, + curveFeeBps, + curveKind: initializer.CURVE_KIND_XYK, + curveParams: new Uint8Array([initializer.CURVE_PARAMS_FORMAT_XYK_V0]), + allowBuy, + allowSell, + sentinelProgram: SOLANA_SYSTEM_PROGRAM_ADDRESS, + sentinelFlags: 0, + sentinelCalldata: new Uint8Array(), + migratorInitCalldata: new Uint8Array(), + migratorMigrateCalldata: new Uint8Array(), + sentinelRemainingAccountsHash: initializer.EMPTY_REMAINING_ACCOUNTS_HASH, + migratorRemainingAccountsHash: initializer.EMPTY_REMAINING_ACCOUNTS_HASH, + metadataName: input.tokenMetadata.name, + metadataSymbol: input.tokenMetadata.symbol, + metadataUri: input.tokenMetadata.tokenURI, + }, + ); + + const latestBlockhash = await this.rpc.getLatestBlockhash({ commitment: 'confirmed' }).send(); + const transactionMessage = appendTransactionMessageInstructions( + [instruction], + setTransactionMessageLifetimeUsingBlockhash( + latestBlockhash.value, + setTransactionMessageFeePayerSigner(payer, createTransactionMessage({ version: 0 })), + ), + ); + + const signedTransaction = await signTransactionMessageWithSigners(transactionMessage); + const feePayerSignature = signedTransaction.signatures[payer.address]; + if (!feePayerSignature) { + throw new AppError( + 500, + 'SOLANA_SUBMISSION_FAILED', + 'Failed to sign Solana launch transaction with the configured payer', + ); + } + + const transactionSignature = signature(getBase58Decoder().decode(feePayerSignature)); + const explorerUrl = buildExplorerUrl(input.network, transactionSignature); + const wireTransaction = getBase64EncodedWireTransaction(signedTransaction); + + let simulation: { value: { err: unknown; logs: string[] | null } }; + try { + simulation = await this.rpc + .simulateTransaction(wireTransaction, { + commitment: 'confirmed', + encoding: 'base64', + replaceRecentBlockhash: false, + sigVerify: true, + }) + .send(); + } catch (error) { + throw new AppError( + 502, + 'SOLANA_SIMULATION_FAILED', + 'Failed to simulate Solana launch transaction', + { cause: errorMessage(error) }, + ); + } + + const simulationLogs = simulation.value.logs ?? []; + if (simulation.value.err) { + const parsedError = cpmm.parseErrorFromLogs(simulationLogs); + throw new AppError( + 422, + 'SOLANA_SIMULATION_FAILED', + parsedError?.message ?? 'Solana launch simulation failed', + parsedError + ? { + programError: { + code: parsedError.code, + codeName: parsedError.codeName, + message: parsedError.message, + }, + logs: simulationLogs, + } + : { logs: simulationLogs }, + ); + } + + try { + await this.rpc + .sendTransaction(wireTransaction, { + encoding: 'base64', + maxRetries: 0n, + preflightCommitment: 'confirmed', + skipPreflight: true, + }) + .send(); + } catch (error) { + throw new AppError( + 502, + 'SOLANA_SUBMISSION_FAILED', + 'Failed to submit Solana launch transaction', + { cause: errorMessage(error) }, + ); + } + + const deadline = Date.now() + this.config.solana.confirmTimeoutMs; + try { + for (;;) { + const statuses = await this.rpc.getSignatureStatuses([transactionSignature]).send(); + const status = statuses.value[0]; + if (status?.err) { + throw new AppError( + 502, + 'SOLANA_SUBMISSION_FAILED', + 'Solana launch transaction was rejected after submission', + { status: status.err }, + ); + } + + if ( + status?.confirmationStatus === 'confirmed' || + status?.confirmationStatus === 'finalized' + ) { + break; + } + + if (Date.now() >= deadline) { + throw new AppError( + 409, + 'SOLANA_LAUNCH_IN_DOUBT', + 'Solana launch submission completed but confirmation did not resolve before timeout', + { + launchId: launchAddress, + signature: transactionSignature, + explorerUrl, + }, + ); + } + + await delay(SOLANA_CONFIRM_POLL_INTERVAL_MS); + } + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + 409, + 'SOLANA_LAUNCH_IN_DOUBT', + 'Solana launch submission completed but confirmation could not be verified', + { + launchId: launchAddress, + signature: transactionSignature, + explorerUrl, + }, + ); + } + + return { + launchId: launchAddress, + network: input.network, + signature: transactionSignature, + explorerUrl, + predicted: { + tokenAddress: baseMint.address, + launchAuthorityAddress, + baseVaultAddress: baseVault.address, + quoteVaultAddress: quoteVault.address, + }, + effectiveConfig: { + tokensForSale: totalSupply.toString(), + allocationAmount: '0', + allocationLockMode: 'none', + numeraireAddress, + numerairePriceUsd, + curveVirtualBase: curveConfig.curveVirtualBase.toString(), + curveVirtualQuote: curveConfig.curveVirtualQuote.toString(), + curveFeeBps, + allowBuy, + allowSell, + tokenDecimals: SOLANA_TOKEN_DECIMALS, + }, + }; + } +} + +export const SOLANA_CONSTANTS = { + tokenDecimals: SOLANA_TOKEN_DECIMALS, + wsolMintAddress: SOLANA_WSOL_MINT_ADDRESS, +}; diff --git a/src/modules/pricing/provider.ts b/src/modules/pricing/provider.ts index c92208a..1a9854e 100644 --- a/src/modules/pricing/provider.ts +++ b/src/modules/pricing/provider.ts @@ -9,4 +9,5 @@ export interface PriceRequest { export interface PriceProvider { readonly name: string; getUsdPrice(request: PriceRequest): Promise; + getUsdPriceByAssetId?(assetId: string): Promise; } diff --git a/src/modules/pricing/providers/coingecko.ts b/src/modules/pricing/providers/coingecko.ts index af141f3..e6a76e1 100644 --- a/src/modules/pricing/providers/coingecko.ts +++ b/src/modules/pricing/providers/coingecko.ts @@ -16,28 +16,12 @@ export class CoingeckoPriceProvider implements PriceProvider { this.config = config; } - async getUsdPrice(request: PriceRequest): Promise { - if (!request.defaultNumeraireAddress) { - throw new AppError( - 422, - 'PRICE_UNSUPPORTED_NUMERAIRE', - `No default numeraire configured for chain ${request.chainId}; explicit pricing.numerairePriceUsd is required`, - ); - } - - if (request.numeraireAddress.toLowerCase() !== request.defaultNumeraireAddress.toLowerCase()) { - throw new AppError( - 422, - 'PRICE_UNSUPPORTED_NUMERAIRE', - `Auto pricing currently supports only the default numeraire for chain ${request.chainId}; provide pricing.numerairePriceUsd override`, - ); - } - + private async fetchUsdPriceForAssetId(assetId: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs); const url = new URL('/simple/price', this.config.baseUrl); - url.searchParams.set('ids', this.config.defaultAssetId); + url.searchParams.set('ids', assetId); url.searchParams.set('vs_currencies', 'usd'); try { @@ -58,7 +42,7 @@ export class CoingeckoPriceProvider implements PriceProvider { } const data = (await response.json()) as Record; - const usd = data[this.config.defaultAssetId]?.usd; + const usd = data[assetId]?.usd; if (!usd || !Number.isFinite(usd) || usd <= 0) { throw new AppError( 502, @@ -75,4 +59,28 @@ export class CoingeckoPriceProvider implements PriceProvider { clearTimeout(timeout); } } + + async getUsdPrice(request: PriceRequest): Promise { + if (!request.defaultNumeraireAddress) { + throw new AppError( + 422, + 'PRICE_UNSUPPORTED_NUMERAIRE', + `No default numeraire configured for chain ${request.chainId}; explicit pricing.numerairePriceUsd is required`, + ); + } + + if (request.numeraireAddress.toLowerCase() !== request.defaultNumeraireAddress.toLowerCase()) { + throw new AppError( + 422, + 'PRICE_UNSUPPORTED_NUMERAIRE', + `Auto pricing currently supports only the default numeraire for chain ${request.chainId}; provide pricing.numerairePriceUsd override`, + ); + } + + return this.fetchUsdPriceForAssetId(this.config.defaultAssetId); + } + + async getUsdPriceByAssetId(assetId: string): Promise { + return this.fetchUsdPriceForAssetId(assetId); + } } diff --git a/src/modules/pricing/service.ts b/src/modules/pricing/service.ts index a29a428..eeacf12 100644 --- a/src/modules/pricing/service.ts +++ b/src/modules/pricing/service.ts @@ -84,4 +84,26 @@ export class PricingService { this.cache.set(cacheKey, { value, expiresAt: now + this.cacheTtlMs }); return value; } + + async getUsdPriceByAssetId(assetId: string): Promise { + if (!this.provider?.getUsdPriceByAssetId) { + throw new AppError( + 422, + 'PRICE_REQUIRED', + 'Auto pricing is disabled or unavailable; provide pricing.numerairePriceUsd in the request', + ); + } + + const cacheKey = `asset:${assetId}`; + const cached = this.cache.get(cacheKey); + const now = Date.now(); + + if (cached && cached.expiresAt > now) { + return cached.value; + } + + const value = await this.provider.getUsdPriceByAssetId(assetId); + this.cache.set(cacheKey, { value, expiresAt: now + this.cacheTtlMs }); + return value; + } } diff --git a/src/modules/status/service.ts b/src/modules/status/service.ts index aa52b49..b0199a6 100644 --- a/src/modules/status/service.ts +++ b/src/modules/status/service.ts @@ -3,7 +3,7 @@ import { airlockAbi, computePoolId, v4MulticurveInitializerAbi, -} from '@whetstone-research/doppler-sdk'; +} from '@whetstone-research/doppler-sdk/evm'; import { TransactionNotFoundError, decodeAbiParameters, decodeFunctionData } from 'viem'; import { AppError } from '../../core/errors'; diff --git a/tests/integration/health-ready.test.ts b/tests/integration/health-ready.test.ts index 9b54d72..7f388b0 100644 --- a/tests/integration/health-ready.test.ts +++ b/tests/integration/health-ready.test.ts @@ -74,6 +74,48 @@ describe('health endpoints', () => { error: 'dependency unavailable', }, ], + solana: { + enabled: false, + ok: true, + checks: [], + }, + }); + }); + + it('includes Solana readiness when Solana support is enabled', async () => { + app = await buildTestServer({ + solanaEnabled: true, + solanaReadyCheckFails: true, + }); + + const ready = await app.inject({ + method: 'GET', + url: '/ready', + headers: { 'x-api-key': 'test-key' }, + }); + + expect(ready.statusCode).toBe(503); + expect(ready.json()).toEqual({ + status: 'degraded', + checks: [ + { + chainId: 84532, + ok: true, + latestBlock: '123', + }, + ], + solana: { + enabled: true, + network: 'solanaDevnet', + ok: false, + checks: [ + { + name: 'rpcReachable', + ok: false, + error: 'dependency unavailable', + }, + ], + }, }); }); }); diff --git a/tests/integration/multichain-routing.test.ts b/tests/integration/multichain-routing.test.ts index 13337a0..917ddf7 100644 --- a/tests/integration/multichain-routing.test.ts +++ b/tests/integration/multichain-routing.test.ts @@ -49,6 +49,16 @@ describe('GET /v1/capabilities', () => { cacheTtlMs: 1000, coingeckoAssetId: 'ethereum', }, + solana: { + enabled: false, + defaultNetwork: 'solanaDevnet', + devnetRpcUrl: 'http://127.0.0.1:8899', + devnetWsUrl: 'ws://127.0.0.1:8900', + confirmTimeoutMs: 60_000, + useAlt: false, + priceMode: 'required', + coingeckoAssetId: 'solana', + }, chains: { 84532: { chainId: 84532, @@ -106,6 +116,12 @@ describe('GET /v1/capabilities', () => { isEnabled: () => true, getProviderName: () => 'coingecko', } as any, + solanaLaunchService: { + getReadiness: async () => ({ enabled: false, ok: true, checks: [] }), + createLaunch: async () => { + throw new Error('not used'); + }, + } as any, launchService: { createLaunch: async () => { throw new Error('not used'); @@ -137,6 +153,15 @@ describe('GET /v1/capabilities', () => { multicurveInitializers: string[]; }>; pricing: { provider: string }; + solana: { + enabled: boolean; + supportedNetworks: string[]; + unsupportedNetworks: string[]; + dedicatedRouteInputAliases: string[]; + creationOnly: boolean; + numeraireAddress: string; + priceResolutionModes: string[]; + }; }; expect(body.chains).toHaveLength(2); expect(body.pricing.provider).toBe('coingecko'); @@ -147,5 +172,142 @@ describe('GET /v1/capabilities', () => { expect(byChain.get(8453)?.governanceEnabled).toBe(true); expect(byChain.get(8453)?.governanceModes).toEqual(['noOp', 'default']); expect(byChain.get(8453)?.multicurveInitializers).toEqual(['standard']); + expect(body.solana).toEqual({ + enabled: false, + supportedNetworks: [], + unsupportedNetworks: ['solanaDevnet', 'solanaMainnetBeta'], + dedicatedRouteInputAliases: ['devnet', 'mainnet-beta'], + creationOnly: true, + numeraireAddress: 'So11111111111111111111111111111111111111112', + priceResolutionModes: ['request'], + }); + }); + + it('reports Solana creation capabilities when enabled', async () => { + const config: AppConfig = { + port: 3000, + deploymentMode: 'standalone', + apiKey: 'test-key', + apiKeys: ['test-key'], + defaultChainId: 84532, + privateKey: '0x59c6995e998f97a5a0044966f0945386f3f6f3d1063f4042afe30de8f34a4c9e', + logLevel: 'silent', + readyRpcTimeoutMs: 1000, + corsOrigins: [], + rateLimit: { + max: 100, + timeWindowMs: 60_000, + }, + redis: { + keyPrefix: 'doppler-api-test', + }, + idempotency: { + enabled: true, + backend: 'file', + requireKey: false, + ttlMs: 86_400_000, + storePath: '.test-results/test-idempotency.json', + redisLockTtlMs: 900_000, + redisLockRefreshMs: 300_000, + }, + pricing: { + enabled: true, + provider: 'coingecko', + baseUrl: 'https://api.coingecko.com/api/v3', + timeoutMs: 1000, + cacheTtlMs: 1000, + coingeckoAssetId: 'ethereum', + }, + solana: { + enabled: true, + defaultNetwork: 'solanaDevnet', + devnetRpcUrl: 'http://127.0.0.1:8899', + devnetWsUrl: 'ws://127.0.0.1:8900', + confirmTimeoutMs: 60_000, + useAlt: true, + altAddress: '7uG2R7ZBTVMcpnSUsctNkYheAQ7EzWLKQEiC5EvhczHx', + priceMode: 'coingecko', + fixedNumerairePriceUsd: 123.45, + coingeckoAssetId: 'solana', + }, + chains: { + 84532: { + chainId: 84532, + rpcUrl: 'http://localhost:8545', + defaultNumeraireAddress: '0x4200000000000000000000000000000000000006', + auctionTypes: ['multicurve'], + migrationModes: ['noOp'], + governanceModes: ['noOp'], + governanceEnabled: false, + }, + }, + }; + + const fakeChain = { + chainId: 84532, + config: config.chains[84532], + publicClient: { getBlockNumber: async () => 1n }, + walletClient: {}, + addresses: { airlock: '0x0000000000000000000000000000000000000001' }, + } as any; + + const services: AppServices = { + config, + metrics: new MetricsRegistry(), + chainRegistry: { + defaultChainId: 84532, + get: () => fakeChain, + list: () => [fakeChain], + } as any, + sdkRegistry: {} as any, + txSubmitter: {} as any, + idempotencyStore: { + execute: async (_key: string, _payload: unknown, action: () => Promise) => ({ + response: await action(), + replayed: false, + }), + } as any, + pricingService: { + isEnabled: () => true, + getProviderName: () => 'coingecko', + } as any, + solanaLaunchService: { + getReadiness: async () => ({ enabled: true, ok: true, network: 'solanaDevnet', checks: [] }), + createLaunch: async () => { + throw new Error('not used'); + }, + } as any, + launchService: { + createLaunch: async () => { + throw new Error('not used'); + }, + } as any, + statusService: { + getLaunchStatus: async () => { + throw new Error('not used'); + }, + } as any, + }; + + app = await buildServer(services); + + const response = await app.inject({ + method: 'GET', + url: '/v1/capabilities', + headers: { + 'x-api-key': 'test-key', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().solana).toEqual({ + enabled: true, + supportedNetworks: ['solanaDevnet'], + unsupportedNetworks: ['solanaMainnetBeta'], + dedicatedRouteInputAliases: ['devnet', 'mainnet-beta'], + creationOnly: true, + numeraireAddress: 'So11111111111111111111111111111111111111112', + priceResolutionModes: ['request', 'fixed', 'coingecko'], + }); }); }); diff --git a/tests/integration/server-startup.test.ts b/tests/integration/server-startup.test.ts index 7e2a578..d6f629d 100644 --- a/tests/integration/server-startup.test.ts +++ b/tests/integration/server-startup.test.ts @@ -41,6 +41,16 @@ describe('server startup', () => { cacheTtlMs: 1000, coingeckoAssetId: 'ethereum', }, + solana: { + enabled: false, + defaultNetwork: 'solanaDevnet', + devnetRpcUrl: 'http://127.0.0.1:8899', + devnetWsUrl: 'ws://127.0.0.1:8900', + confirmTimeoutMs: 60_000, + useAlt: false, + priceMode: 'required', + coingeckoAssetId: 'solana', + }, chains: { 84532: { chainId: 84532, @@ -84,6 +94,12 @@ describe('server startup', () => { isEnabled: () => false, getProviderName: () => 'none', } as any, + solanaLaunchService: { + getReadiness: async () => ({ enabled: false, ok: true, checks: [] }), + createLaunch: async () => { + throw new Error('not used'); + }, + } as any, launchService: { createLaunch: async () => { throw new Error('not used'); diff --git a/tests/integration/solana-launches.test.ts b/tests/integration/solana-launches.test.ts new file mode 100644 index 0000000..e876497 --- /dev/null +++ b/tests/integration/solana-launches.test.ts @@ -0,0 +1,496 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildTestServer } from './test-server'; + +describe('Solana create routes', () => { + let app: Awaited> | null = null; + + afterEach(async () => { + if (app) { + await app.close(); + app = null; + } + }); + + it('creates a Solana launch on the dedicated route and normalizes short network aliases', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/solana/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + network: 'devnet', + tokenMetadata: { name: 'Solana Token', symbol: 'SOLT', tokenURI: 'ipfs://solana-token' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.network).toBe('solanaDevnet'); + expect(body.launchId).not.toContain(':'); + expect(body.signature).toBeDefined(); + expect(body.statusUrl).toBeUndefined(); + expect(body.predicted.launchAuthorityAddress).toBeDefined(); + }); + + it('defaults the dedicated Solana route network from server config when omitted', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/solana/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + tokenMetadata: { name: 'Default Network', symbol: 'DNET', tokenURI: 'ipfs://default' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().network).toBe('solanaDevnet'); + }); + + it('accepts Solana payloads on the generic route only with canonical prefixed networks', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + network: 'solanaDevnet', + tokenMetadata: { name: 'Generic Solana', symbol: 'GSOL', tokenURI: 'ipfs://gsol' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + curveFeeBps: 25, + allowBuy: true, + allowSell: false, + }, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.network).toBe('solanaDevnet'); + expect(body.effectiveConfig.curveFeeBps).toBe(25); + expect(body.effectiveConfig.allowSell).toBe(false); + }); + + it('rejects short Solana network aliases on the generic route', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + network: 'devnet', + tokenMetadata: { name: 'Wrong Generic', symbol: 'WGEN', tokenURI: 'ipfs://wrong' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(422); + expect(response.json()).toEqual({ + error: { + code: 'INVALID_REQUEST', + message: + 'Solana requests on POST /v1/launches must use network "solanaDevnet" or "solanaMainnetBeta"', + }, + }); + }); + + it('rejects unsupported Solana-only payload fields instead of ignoring them', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/solana/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + tokenMetadata: { name: 'Strict Token', symbol: 'STRICT', tokenURI: 'ipfs://strict' }, + economics: { + totalSupply: '1000', + tokensForSale: '500', + }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(422); + expect(response.json().error.code).toBe('INVALID_REQUEST'); + }); + + it('maps Solana metadata validation failures to SOLANA_INVALID_METADATA', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/solana/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + tokenMetadata: { + name: 'a'.repeat(33), + symbol: 'META', + tokenURI: 'ipfs://metadata', + }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(422); + expect(response.json().error.code).toBe('SOLANA_INVALID_METADATA'); + }); + + it('maps Solana curve validation failures to SOLANA_INVALID_CURVE', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/solana/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + tokenMetadata: { name: 'Curve Token', symbol: 'CURVE', tokenURI: 'ipfs://curve' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 1000, + marketCapEndUsd: 100, + }, + }, + }, + }); + + expect(response.statusCode).toBe(422); + expect(response.json().error.code).toBe('SOLANA_INVALID_CURVE'); + }); + + it('replays Solana create requests when the idempotency key and payload match', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const payload = { + network: 'solanaDevnet', + tokenMetadata: { name: 'Replay Token', symbol: 'RPLY', tokenURI: 'ipfs://replay' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' as const }, + auction: { + type: 'xyk' as const, + curveConfig: { + type: 'range' as const, + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }; + + const first = await app.inject({ + method: 'POST', + url: '/v1/launches', + headers: { + 'x-api-key': 'test-key', + 'idempotency-key': 'solana-replay-key', + }, + payload, + }); + const second = await app.inject({ + method: 'POST', + url: '/v1/launches', + headers: { + 'x-api-key': 'test-key', + 'idempotency-key': 'solana-replay-key', + }, + payload, + }); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(200); + expect(second.headers['x-idempotency-replayed']).toBe('true'); + expect(second.json()).toEqual(first.json()); + }); + + it('rejects reuse of a Solana idempotency key with a different payload', async () => { + app = await buildTestServer({ solanaEnabled: true }); + + const first = await app.inject({ + method: 'POST', + url: '/v1/launches', + headers: { + 'x-api-key': 'test-key', + 'idempotency-key': 'solana-mismatch-key', + }, + payload: { + network: 'solanaDevnet', + tokenMetadata: { name: 'Mismatch A', symbol: 'MSHA', tokenURI: 'ipfs://a' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + const second = await app.inject({ + method: 'POST', + url: '/v1/launches', + headers: { + 'x-api-key': 'test-key', + 'idempotency-key': 'solana-mismatch-key', + }, + payload: { + network: 'solanaDevnet', + tokenMetadata: { name: 'Mismatch B', symbol: 'MSHB', tokenURI: 'ipfs://b' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(409); + expect(second.json()).toEqual({ + error: { + code: 'IDEMPOTENCY_KEY_REUSE_MISMATCH', + message: 'Idempotency-Key was already used with a different request payload', + }, + }); + }); + + it('surfaces SOLANA_NOT_READY on create', async () => { + app = await buildTestServer({ + solanaEnabled: true, + solanaCreateError: { + statusCode: 503, + code: 'SOLANA_NOT_READY', + message: 'Solana devnet is not ready for launch creation', + }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/solana/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + tokenMetadata: { name: 'Not Ready', symbol: 'NORD', tokenURI: 'ipfs://not-ready' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ + error: { + code: 'SOLANA_NOT_READY', + message: 'Internal server error', + }, + }); + }); + + it('surfaces Solana simulation and submission failures with stable codes', async () => { + for (const errorCase of [ + { + code: 'SOLANA_SIMULATION_FAILED', + statusCode: 422, + }, + { + code: 'SOLANA_SUBMISSION_FAILED', + statusCode: 502, + }, + ]) { + app = await buildTestServer({ + solanaEnabled: true, + solanaCreateError: { + statusCode: errorCase.statusCode, + code: errorCase.code, + message: `${errorCase.code} message`, + }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/solana/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + tokenMetadata: { name: 'Failed Launch', symbol: 'FAIL', tokenURI: 'ipfs://fail' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(errorCase.statusCode); + expect(response.json().error.code).toBe(errorCase.code); + + await app.close(); + app = null; + } + }); + + it('includes launch details when Solana confirmation is in doubt', async () => { + app = await buildTestServer({ + solanaEnabled: true, + solanaCreateError: { + statusCode: 409, + code: 'SOLANA_LAUNCH_IN_DOUBT', + message: 'Solana launch confirmation is in doubt', + details: { + launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', + signature: + '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', + explorerUrl: + 'https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet', + }, + }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/launches', + headers: { + 'x-api-key': 'test-key', + }, + payload: { + network: 'solanaDevnet', + tokenMetadata: { name: 'In Doubt', symbol: 'DOUBT', tokenURI: 'ipfs://doubt' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toEqual({ + error: { + code: 'SOLANA_LAUNCH_IN_DOUBT', + message: 'Solana launch confirmation is in doubt', + details: { + launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', + signature: + '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', + explorerUrl: + 'https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet', + }, + }, + }); + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index daf3632..26fb97e 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -1,11 +1,37 @@ import { buildServer, type AppServices } from '../../src/app/server'; import type { AppConfig } from '../../src/core/config'; +import { AppError } from '../../src/core/errors'; import { MetricsRegistry } from '../../src/core/metrics'; interface BuildTestServerOptions { readyCheckFails?: boolean; + solanaEnabled?: boolean; + solanaReadyCheckFails?: boolean; + solanaCreateError?: { + statusCode: number; + code: string; + message: string; + details?: unknown; + }; } +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + + const entries = Object.entries(value as Record).sort(([left], [right]) => + left.localeCompare(right), + ); + return `{${entries + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`) + .join(',')}}`; +}; + export const buildTestServer = async (options: BuildTestServerOptions = {}) => { const config: AppConfig = { port: 3000, @@ -41,6 +67,16 @@ export const buildTestServer = async (options: BuildTestServerOptions = {}) => { cacheTtlMs: 1000, coingeckoAssetId: 'ethereum', }, + solana: { + enabled: options.solanaEnabled ?? false, + defaultNetwork: 'solanaDevnet', + devnetRpcUrl: 'http://127.0.0.1:8899', + devnetWsUrl: 'ws://127.0.0.1:8900', + confirmTimeoutMs: 60_000, + useAlt: false, + priceMode: 'required', + coingeckoAssetId: 'solana', + }, chains: { 84532: { chainId: 84532, @@ -53,6 +89,14 @@ export const buildTestServer = async (options: BuildTestServerOptions = {}) => { }, }, }; + const solanaReadinessChecks = options.solanaReadyCheckFails + ? [{ name: 'rpcReachable', ok: false, error: 'dependency unavailable' }] + : [ + { name: 'rpcReachable', ok: true }, + { name: 'latestBlockhash', ok: true }, + { name: 'initializerConfig', ok: true }, + { name: 'addressLookupTable', ok: true }, + ]; const fakeChain = { chainId: 84532, @@ -143,6 +187,102 @@ export const buildTestServer = async (options: BuildTestServerOptions = {}) => { }; }; + const buildSolanaLaunchResponse = (payload?: { + network?: 'solanaDevnet' | 'solanaMainnetBeta'; + economics?: { totalSupply?: string }; + pairing?: { numeraireAddress?: string }; + pricing?: { numerairePriceUsd?: number }; + auction?: { + curveConfig?: { + marketCapStartUsd?: number; + marketCapEndUsd?: number; + }; + curveFeeBps?: number; + allowBuy?: boolean; + allowSell?: boolean; + }; + }) => { + const totalSupply = payload?.economics?.totalSupply ?? '1000'; + + return { + launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', + network: payload?.network ?? 'solanaDevnet', + signature: '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', + explorerUrl: + 'https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet', + predicted: { + tokenAddress: '6QWeT6FpJrm8AF1btu6WH2k2Xhq6t5vbheKVfQavmeoZ', + launchAuthorityAddress: 'E7Ud4m8S7fC2YdUQdL7p9V2sRrMfQjQ9fA5spuR4T9gQ', + baseVaultAddress: '9xQeWvG816bUx9EPjHmaT23yvVMHh2eHq9cYqB9Yg6xT', + quoteVaultAddress: 'J1veWvV6BF8L7rN8D66zCFAaj6MqFmoVoeAQMtkP8dwF', + }, + effectiveConfig: { + tokensForSale: totalSupply, + allocationAmount: '0', + allocationLockMode: 'none' as const, + numeraireAddress: + payload?.pairing?.numeraireAddress ?? 'So11111111111111111111111111111111111111112', + numerairePriceUsd: payload?.pricing?.numerairePriceUsd ?? 100, + curveVirtualBase: '1000000000', + curveVirtualQuote: '100000000', + curveFeeBps: payload?.auction?.curveFeeBps ?? 0, + allowBuy: payload?.auction?.allowBuy ?? true, + allowSell: payload?.auction?.allowSell ?? true, + tokenDecimals: 9, + }, + }; + }; + + const idempotencyResults = new Map(); + + const resolveLaunchResponse = (input: unknown) => { + if (typeof input === 'object' && input !== null && 'network' in input) { + const solanaInput = input as { + network?: 'solanaDevnet' | 'solanaMainnetBeta'; + auction?: { + curveConfig?: { + marketCapStartUsd?: number; + marketCapEndUsd?: number; + }; + }; + }; + + if (solanaInput.network === 'solanaMainnetBeta') { + throw new AppError( + 501, + 'SOLANA_NETWORK_UNSUPPORTED', + 'solanaMainnetBeta is scaffolded but not executable in this API profile', + ); + } + + if ( + solanaInput.auction?.curveConfig?.marketCapStartUsd !== undefined && + solanaInput.auction?.curveConfig?.marketCapEndUsd !== undefined && + solanaInput.auction.curveConfig.marketCapEndUsd <= + solanaInput.auction.curveConfig.marketCapStartUsd + ) { + throw new AppError( + 422, + 'SOLANA_INVALID_CURVE', + 'marketCapEndUsd must be greater than marketCapStartUsd', + ); + } + + if (options.solanaCreateError) { + throw new AppError( + options.solanaCreateError.statusCode, + options.solanaCreateError.code, + options.solanaCreateError.message, + options.solanaCreateError.details, + ); + } + + return buildSolanaLaunchResponse(solanaInput); + } + + return buildLaunchResponse(input as Parameters[0]); + }; + const services: AppServices = { config, metrics: new MetricsRegistry(), @@ -163,14 +303,58 @@ export const buildTestServer = async (options: BuildTestServerOptions = {}) => { isEnabled: () => false, getProviderName: () => 'none', } as any, + solanaLaunchService: { + getReadiness: async () => + config.solana.enabled + ? { + enabled: true, + network: 'solanaDevnet', + ok: !options.solanaReadyCheckFails, + checks: solanaReadinessChecks, + } + : { enabled: false, ok: true, checks: [] }, + createLaunch: async () => { + throw new Error('not used'); + }, + } as any, launchService: { createLaunch: async (payload?: { governance?: unknown }) => { - return buildLaunchResponse(payload as any); + return resolveLaunchResponse(payload); }, - createLaunchWithIdempotency: async (payload?: { governance?: unknown; input?: any }) => { + createLaunchWithIdempotency: async (payload?: { governance?: unknown; input?: unknown; idempotencyKey?: string }) => { + const key = payload?.idempotencyKey?.trim(); + const input = payload?.input; + + if (!key) { + return { + replayed: false, + response: resolveLaunchResponse(input), + }; + } + + const payloadHash = stableStringify(input); + const existing = idempotencyResults.get(key); + if (existing) { + if (existing.payloadHash !== payloadHash) { + throw new AppError( + 409, + 'IDEMPOTENCY_KEY_REUSE_MISMATCH', + 'Idempotency-Key was already used with a different request payload', + ); + } + + return { + replayed: true, + response: existing.response, + }; + } + + const response = resolveLaunchResponse(input); + idempotencyResults.set(key, { payloadHash, response }); + return { replayed: false, - response: buildLaunchResponse((payload as any)?.input), + response, }; }, } as any, diff --git a/tests/live/create-and-verify.test.ts b/tests/live/create-and-verify.test.ts index 8336447..ea19767 100644 --- a/tests/live/create-and-verify.test.ts +++ b/tests/live/create-and-verify.test.ts @@ -1,10 +1,17 @@ import { afterAll, beforeAll, describe } from 'vitest'; +import { createKeyPairSignerFromBytes, createSolanaRpc } from '@solana/kit'; import { formatEther } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { buildServices } from '../../src/app/server'; import { loadConfig } from '../../src/core/config'; -import { buildLiveBalanceRequirement, LIVE_READINESS_ERROR_MARKER } from './readiness-check'; +import { + buildLiveBalanceRequirement, + buildLiveSolanaBalanceRequirement, + formatSolAmount, + isSolanaLiveFilter, + LIVE_READINESS_ERROR_MARKER, +} from './readiness-check'; import { launchSummaries, liveFilter, @@ -15,6 +22,7 @@ import { } from './helpers/live-support'; import { registerDynamicLiveScenarios } from './scenarios/dynamic.live'; import { registerMulticurveLiveScenarios } from './scenarios/multicurve.live'; +import { registerSolanaLiveScenarios } from './scenarios/solana.live'; import { registerStaticLiveScenarios } from './scenarios/static.live'; describe('live create verification', () => { @@ -22,6 +30,58 @@ describe('live create verification', () => { if (!runLive) return; const config = loadConfig(); + + if (isSolanaLiveFilter(liveFilter)) { + if (!config.solana.enabled) { + throw new Error( + `[${LIVE_READINESS_ERROR_MARKER}] LIVE_TEST_FILTER=${liveFilter} requires SOLANA_ENABLED=true.`, + ); + } + if (!config.solana.keypairBytes) { + throw new Error( + `[${LIVE_READINESS_ERROR_MARKER}] LIVE_TEST_FILTER=${liveFilter} requires SOLANA_KEYPAIR to be configured.`, + ); + } + + let requirement: ReturnType; + try { + requirement = buildLiveSolanaBalanceRequirement({ + liveFilter, + minBalanceSol: process.env.LIVE_TEST_MIN_BALANCE_SOL, + estimatedTxCostSol: process.env.LIVE_TEST_ESTIMATED_TX_COST_SOL, + estimatedOverheadSol: process.env.LIVE_TEST_ESTIMATED_OVERHEAD_SOL, + }); + } catch (error) { + throw new Error( + `[${LIVE_READINESS_ERROR_MARKER}] Invalid live test readiness configuration: ${toShortError(error)}.`, + { cause: error as Error }, + ); + } + + if (!requirement) return; + + const rpc = createSolanaRpc(config.solana.devnetRpcUrl); + const payer = await createKeyPairSignerFromBytes(config.solana.keypairBytes); + let payerBalance: bigint; + try { + const balance = await rpc.getBalance(payer.address, { commitment: 'confirmed' }).send(); + payerBalance = balance.value; + } catch (error) { + throw new Error( + `[${LIVE_READINESS_ERROR_MARKER}] Could not fetch SOL balance for payer ${payer.address} on ${config.solana.devnetRpcUrl}. Ensure SOLANA_DEVNET_RPC_URL is reachable before running live Solana tests.`, + { cause: error as Error }, + ); + } + + if (payerBalance < requirement.requiredLamports) { + throw new Error( + `[${LIVE_READINESS_ERROR_MARKER}] Insufficient estimated SOL balance for LIVE_TEST_FILTER=${liveFilter}. Payer ${payer.address} has ${formatSolAmount(payerBalance)} SOL, but requires at least ${formatSolAmount(requirement.requiredLamports)} SOL (${requirement.reason}). Fund this payer or adjust LIVE_TEST_MIN_BALANCE_SOL / LIVE_TEST_ESTIMATED_TX_COST_SOL / LIVE_TEST_ESTIMATED_OVERHEAD_SOL.`, + ); + } + + return; + } + const services = buildServices(config); const chain = services.chainRegistry.get(config.defaultChainId); const signerAddress = privateKeyToAccount(config.privateKey).address; @@ -71,7 +131,7 @@ describe('live create verification', () => { 'Recipients', 'Vest Mode', 'Vest Duration (s)', - 'Pure URL', + 'Reference URL', ], launchSummaries.map((row) => [ row.config, @@ -98,4 +158,5 @@ describe('live create verification', () => { registerMulticurveLiveScenarios(); registerStaticLiveScenarios(); registerDynamicLiveScenarios(); + registerSolanaLiveScenarios(); }); diff --git a/tests/live/helpers/live-support.ts b/tests/live/helpers/live-support.ts index dc34ea7..2453098 100644 --- a/tests/live/helpers/live-support.ts +++ b/tests/live/helpers/live-support.ts @@ -4,7 +4,7 @@ import { computePoolId, decayMulticurveInitializerHookAbi, v4MulticurveInitializerAbi, -} from '@whetstone-research/doppler-sdk'; +} from '@whetstone-research/doppler-sdk/evm'; import { decodeAbiParameters, decodeFunctionData, parseEther, zeroAddress } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { randomBytes } from 'node:crypto'; @@ -28,12 +28,17 @@ type LiveScenarioGroup = | 'multicurve-defaults' | 'fees' | 'negative' - | 'governance'; + | 'governance' + | 'solana' + | 'solana-devnet' + | 'solana-defaults' + | 'solana-random' + | 'solana-failing'; const shouldRunScenario = (groups: LiveScenarioGroup[]): boolean => { if (!runLive) return false; - if (liveFilter === 'all') return true; + if (liveFilter === 'all') return !groups.includes('solana'); if (liveFilter === 'static') return groups.includes('static'); if (liveFilter === 'dynamic') return groups.includes('dynamic'); if (liveFilter === 'migration-v2') return groups.includes('migration-v2'); @@ -47,6 +52,11 @@ const shouldRunScenario = (groups: LiveScenarioGroup[]): boolean => { if (liveFilter === 'fees') return groups.includes('fees'); if (liveFilter === 'governance') return groups.includes('governance'); if (liveFilter === 'negative') return groups.includes('negative'); + if (liveFilter === 'solana') return groups.includes('solana'); + if (liveFilter === 'solana-devnet') return groups.includes('solana-devnet'); + if (liveFilter === 'solana-defaults') return groups.includes('solana-defaults'); + if (liveFilter === 'solana-random') return groups.includes('solana-random'); + if (liveFilter === 'solana-failing') return groups.includes('solana-failing'); return true; }; diff --git a/tests/live/readiness-check.ts b/tests/live/readiness-check.ts index 930c334..0b0ee88 100644 --- a/tests/live/readiness-check.ts +++ b/tests/live/readiness-check.ts @@ -3,21 +3,33 @@ import { parseEther } from 'viem'; export const LIVE_READINESS_ERROR_MARKER = 'LIVE_TEST_READINESS_CHECK_FAILED'; export const DEFAULT_LIVE_ESTIMATED_TX_COST_ETH = '0.000133333333333333'; export const DEFAULT_LIVE_ESTIMATED_OVERHEAD_ETH = '0.000133333333333333'; +export const DEFAULT_LIVE_ESTIMATED_TX_COST_SOL = '0.025'; +export const DEFAULT_LIVE_ESTIMATED_OVERHEAD_SOL = '0.01'; +const LAMPORTS_PER_SOL = 1_000_000_000n; -const estimatedLaunchesByFilter: Record = { - all: 19, - static: 3, - dynamic: 4, - 'migration-v2': 1, - 'migration-v4': 1, - multicurve: 12, - 'multicurve-defaults': 3, - fees: 3, - governance: 3, - negative: 0, +const estimatedLaunchesByFilter: Record = { + all: { evm: 19, solana: 0 }, + static: { evm: 3, solana: 0 }, + dynamic: { evm: 4, solana: 0 }, + 'migration-v2': { evm: 1, solana: 0 }, + 'migration-v4': { evm: 1, solana: 0 }, + multicurve: { evm: 12, solana: 0 }, + 'multicurve-defaults': { evm: 3, solana: 0 }, + fees: { evm: 3, solana: 0 }, + governance: { evm: 3, solana: 0 }, + negative: { evm: 0, solana: 0 }, + solana: { evm: 0, solana: 4 }, + 'solana-devnet': { evm: 0, solana: 4 }, + 'solana-defaults': { evm: 0, solana: 2 }, + 'solana-random': { evm: 0, solana: 1 }, + 'solana-failing': { evm: 0, solana: 0 }, }; const normalizeFilter = (liveFilter: string): string => liveFilter.trim().toLowerCase(); +export const isSolanaLiveFilter = (liveFilter: string): boolean => { + const normalized = normalizeFilter(liveFilter); + return normalized === 'solana' || normalized.startsWith('solana-'); +}; const parseEthAmount = (value: string, envName: string): bigint => { try { @@ -36,9 +48,29 @@ const parseEthAmount = (value: string, envName: string): bigint => { } }; +const parseSolAmount = (value: string, envName: string): bigint => { + const trimmed = value.trim(); + const match = trimmed.match(/^(\d+)(?:\.(\d{1,9}))?$/); + if (!match) { + throw new Error( + `${envName} must be a non-negative SOL amount with at most 9 decimal places (received "${value || '(empty)'}")`, + ); + } + + const whole = BigInt(match[1] ?? '0'); + const fractionalDigits = (match[2] ?? '').padEnd(9, '0'); + return whole * LAMPORTS_PER_SOL + BigInt(fractionalDigits || '0'); +}; + +export const formatSolAmount = (lamports: bigint): string => { + const whole = lamports / LAMPORTS_PER_SOL; + const fractional = (lamports % LAMPORTS_PER_SOL).toString().padStart(9, '0').replace(/0+$/, ''); + return fractional ? `${whole}.${fractional}` : whole.toString(); +}; + export const estimateLiveLaunchCount = (liveFilter: string): number => { const normalizedFilter = normalizeFilter(liveFilter); - return estimatedLaunchesByFilter[normalizedFilter] ?? estimatedLaunchesByFilter.all; + return (estimatedLaunchesByFilter[normalizedFilter] ?? estimatedLaunchesByFilter.all).evm; }; export interface LiveBalanceRequirement { @@ -47,6 +79,12 @@ export interface LiveBalanceRequirement { estimatedLaunchCount: number; } +export interface LiveSolanaBalanceRequirement { + requiredLamports: bigint; + reason: string; + estimatedLaunchCount: number; +} + export const buildLiveBalanceRequirement = (args: { liveFilter: string; minBalanceEth?: string; @@ -83,3 +121,50 @@ export const buildLiveBalanceRequirement = (args: { estimatedLaunchCount, }; }; + +export const estimateLiveSolanaLaunchCount = (liveFilter: string): number => { + const normalizedFilter = normalizeFilter(liveFilter); + return (estimatedLaunchesByFilter[normalizedFilter] ?? estimatedLaunchesByFilter.all).solana; +}; + +export const buildLiveSolanaBalanceRequirement = (args: { + liveFilter: string; + minBalanceSol?: string; + estimatedTxCostSol?: string; + estimatedOverheadSol?: string; +}): LiveSolanaBalanceRequirement | null => { + const minBalanceOverride = args.minBalanceSol?.trim(); + const estimatedLaunchCount = estimateLiveSolanaLaunchCount(args.liveFilter); + + if (minBalanceOverride) { + return { + requiredLamports: parseSolAmount(minBalanceOverride, 'LIVE_TEST_MIN_BALANCE_SOL'), + reason: `override via LIVE_TEST_MIN_BALANCE_SOL=${minBalanceOverride} SOL`, + estimatedLaunchCount, + }; + } + + if (estimatedLaunchCount === 0) { + return null; + } + + const estimatedTxCostSol = + args.estimatedTxCostSol?.trim() || DEFAULT_LIVE_ESTIMATED_TX_COST_SOL; + const estimatedOverheadSol = + args.estimatedOverheadSol?.trim() || DEFAULT_LIVE_ESTIMATED_OVERHEAD_SOL; + const estimatedTxCostLamports = parseSolAmount( + estimatedTxCostSol, + 'LIVE_TEST_ESTIMATED_TX_COST_SOL', + ); + const estimatedOverheadLamports = parseSolAmount( + estimatedOverheadSol, + 'LIVE_TEST_ESTIMATED_OVERHEAD_SOL', + ); + + return { + requiredLamports: + estimatedOverheadLamports + estimatedTxCostLamports * BigInt(estimatedLaunchCount), + reason: `estimate: ${estimatedLaunchCount} launch tx * ${estimatedTxCostSol} SOL + ${estimatedOverheadSol} SOL overhead`, + estimatedLaunchCount, + }; +}; diff --git a/tests/live/scenarios/multicurve.live.ts b/tests/live/scenarios/multicurve.live.ts index 1cd391c..6798da5 100644 --- a/tests/live/scenarios/multicurve.live.ts +++ b/tests/live/scenarios/multicurve.live.ts @@ -1,4 +1,4 @@ -import { WAD } from '@whetstone-research/doppler-sdk'; +import { WAD } from '@whetstone-research/doppler-sdk/evm'; import { privateKeyToAccount } from 'viem/accounts'; import { buildServices } from '../../../src/app/server'; diff --git a/tests/live/scenarios/solana.live.ts b/tests/live/scenarios/solana.live.ts new file mode 100644 index 0000000..80edda1 --- /dev/null +++ b/tests/live/scenarios/solana.live.ts @@ -0,0 +1,569 @@ +import { randomBytes } from 'node:crypto'; + +import { expect } from 'vitest'; +import { + address, + assertAccountExists, + createSolanaRpc, + fetchEncodedAccount, + signature as toSolanaSignature, +} from '@solana/kit'; +import { initializer } from '@whetstone-research/doppler-sdk/solana'; + +import { buildServer, buildServices } from '../../../src/app/server'; +import { loadConfig } from '../../../src/core/config'; +import type { CreateSolanaLaunchResponse } from '../../../src/core/types'; +import { + SOLANA_CONSTANTS, + type CreateSolanaLaunchRequestInput, + type DedicatedSolanaCreateLaunchRequestInput, +} from '../../../src/modules/launches/solana'; +import { + launchSummaries, + liveDivider, + liveIt, + liveVerbose, + printLiveTable, + toShortError, +} from '../helpers/live-support'; + +const SOLANA_LIVE_TIMEOUT_MS = 240_000; +const SOLANA_ACCOUNT_COMMITMENT = { commitment: 'confirmed' as const }; +const SOLANA_NON_WSOL_ADDRESS = '11111111111111111111111111111111'; + +type SolanaLiveRoute = 'dedicated' | 'generic'; +type SolanaLivePayload = + | DedicatedSolanaCreateLaunchRequestInput + | CreateSolanaLaunchRequestInput; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const nextTokenMetadata = (prefix: string) => { + const suffix = randomBytes(3).toString('hex').toUpperCase(); + return { + name: `SOL ${prefix} ${suffix}`.slice(0, 32), + symbol: `${prefix.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 4)}${suffix}`.slice(0, 10), + tokenURI: `ipfs://live-solana/${prefix.toLowerCase()}/${Date.now()}-${suffix.toLowerCase()}`, + }; +}; + +const createLiveApp = async () => { + const config = loadConfig(); + return { + config, + app: await buildServer(buildServices(config)), + }; +}; + +const assertSolanaAccountExists = async ( + rpc: ReturnType, + accountAddress: string, +): Promise => { + const account = await fetchEncodedAccount(rpc, address(accountAddress), SOLANA_ACCOUNT_COMMITMENT); + assertAccountExists(account); +}; + +const waitForConfirmedSignature = async ( + rpc: ReturnType, + signature: string, +): Promise => { + const submittedSignature = toSolanaSignature(signature); + + for (let attempt = 1; attempt <= 20; attempt += 1) { + const statuses = await rpc.getSignatureStatuses([submittedSignature]).send(); + const status = statuses.value[0]; + + if (status?.err) { + throw new Error(`Solana signature ${signature} failed after submission: ${JSON.stringify(status.err)}`); + } + + if (status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') { + return; + } + + await sleep(1_000); + } + + throw new Error(`Solana signature ${signature} did not reach confirmed status in time`); +}; + +const verifySuccessfulSolanaLaunch = async (args: { + configLabel: string; + route: SolanaLiveRoute; + payload: SolanaLivePayload; + replayIdempotency?: boolean; + expectedCurveFeeBps?: number; + expectedAllowBuy?: boolean; + expectedAllowSell?: boolean; + expectedNumerairePriceUsd?: number; +}) => { + const summary: { + config: string; + status: 'created' | 'failed'; + salePercent: string; + allocationAmount: string; + allocationRecipients: string; + vestMode: string; + vestDuration: string; + txHash?: string; + pureUrl?: string; + reason?: string; + } = { + config: args.configLabel, + status: 'failed', + salePercent: '100%', + allocationAmount: '0', + allocationRecipients: '0', + vestMode: 'none', + vestDuration: '0', + }; + let submittedSignature: string | undefined; + let explorerUrl: string | undefined; + + const { config, app } = await createLiveApp(); + const routePath = args.route === 'dedicated' ? '/v1/solana/launches' : '/v1/launches'; + const idempotencyKey = args.replayIdempotency + ? `solana-live-replay-${Date.now()}-${randomBytes(4).toString('hex')}` + : undefined; + + try { + const firstResponse = await app.inject({ + method: 'POST', + url: routePath, + headers: { + 'x-api-key': config.apiKey, + ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}), + }, + payload: args.payload, + }); + + if (firstResponse.statusCode !== 200) { + throw new Error(`Unexpected Solana live response (${firstResponse.statusCode}): ${firstResponse.body}`); + } + + const body = firstResponse.json() as CreateSolanaLaunchResponse; + submittedSignature = body.signature; + explorerUrl = body.explorerUrl; + + expect(body.network).toBe('solanaDevnet'); + expect(body.launchId).not.toContain(':'); + expect(body.signature).toBeTruthy(); + expect(body.explorerUrl).toContain(body.signature); + expect(body.effectiveConfig.tokensForSale).toBe(args.payload.economics.totalSupply); + expect(body.effectiveConfig.allocationAmount).toBe('0'); + expect(body.effectiveConfig.allocationLockMode).toBe('none'); + expect(body.effectiveConfig.numeraireAddress).toBe(String(SOLANA_CONSTANTS.wsolMintAddress)); + expect(body.effectiveConfig.tokenDecimals).toBe(SOLANA_CONSTANTS.tokenDecimals); + expect(body.effectiveConfig.curveFeeBps).toBe(args.expectedCurveFeeBps ?? 0); + expect(body.effectiveConfig.allowBuy).toBe(args.expectedAllowBuy ?? true); + expect(body.effectiveConfig.allowSell).toBe(args.expectedAllowSell ?? true); + if (args.expectedNumerairePriceUsd !== undefined) { + expect(body.effectiveConfig.numerairePriceUsd).toBe(args.expectedNumerairePriceUsd); + } + + const distinctAddresses = new Set([ + body.launchId, + body.predicted.tokenAddress, + body.predicted.launchAuthorityAddress, + body.predicted.baseVaultAddress, + body.predicted.quoteVaultAddress, + ]); + expect(distinctAddresses.size).toBe(5); + + const rpc = createSolanaRpc(config.solana.devnetRpcUrl); + await waitForConfirmedSignature(rpc, body.signature); + + await assertSolanaAccountExists(rpc, body.launchId); + await assertSolanaAccountExists(rpc, body.predicted.tokenAddress); + await assertSolanaAccountExists(rpc, body.predicted.baseVaultAddress); + await assertSolanaAccountExists(rpc, body.predicted.quoteVaultAddress); + + const metadataAddress = await initializer.getTokenMetadataAddress( + address(body.predicted.tokenAddress), + ); + await assertSolanaAccountExists(rpc, metadataAddress); + + const [derivedLaunchAuthorityAddress] = await initializer.getLaunchAuthorityAddress( + address(body.launchId), + ); + expect(derivedLaunchAuthorityAddress).toBe(body.predicted.launchAuthorityAddress); + + if (args.replayIdempotency) { + const replayResponse = await app.inject({ + method: 'POST', + url: routePath, + headers: { + 'x-api-key': config.apiKey, + 'idempotency-key': idempotencyKey!, + }, + payload: args.payload, + }); + + expect(replayResponse.statusCode).toBe(200); + expect(replayResponse.headers['x-idempotency-replayed']).toBe('true'); + expect(replayResponse.json()).toEqual(body); + } + + summary.status = 'created'; + summary.txHash = body.signature; + summary.pureUrl = body.explorerUrl; + } catch (error) { + summary.reason = toShortError(error); + if (liveVerbose) { + // eslint-disable-next-line no-console + console.log(liveDivider(`Solana Live Failure: ${args.configLabel}`)); + printLiveTable('Failure Context', [ + ['Configuration', args.configLabel], + ['Route', routePath], + ['Submitted Signature', submittedSignature ?? 'n/a'], + ['Explorer URL', explorerUrl ?? 'n/a'], + ['Error', summary.reason], + ]); + } + throw error; + } finally { + launchSummaries.push(summary); + await app.close(); + } +}; + +const verifyFailedSolanaLaunch = async (args: { + route: SolanaLiveRoute; + payload: SolanaLivePayload; + expectedStatusCode: number; + expectedCode: string; +}) => { + const { config, app } = await createLiveApp(); + const routePath = args.route === 'dedicated' ? '/v1/solana/launches' : '/v1/launches'; + + try { + const response = await app.inject({ + method: 'POST', + url: routePath, + headers: { + 'x-api-key': config.apiKey, + }, + payload: args.payload, + }); + + expect(response.statusCode).toBe(args.expectedStatusCode); + expect(response.json().error.code).toBe(args.expectedCode); + } finally { + await app.close(); + } +}; + +export const registerSolanaLiveScenarios = () => { + liveIt( + 'SOLANA DEVNET Basic Create', + ['solana', 'solana-devnet', 'solana-defaults'], + async () => { + await verifySuccessfulSolanaLaunch({ + configLabel: 'SOLANA DEVNET Basic Create', + route: 'dedicated', + payload: { + network: 'devnet', + tokenMetadata: nextTokenMetadata('basic'), + economics: { + totalSupply: '1000000000', + }, + pricing: { + numerairePriceUsd: 150, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + expectedNumerairePriceUsd: 150, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); + + liveIt( + 'SOLANA DEVNET Complicated Create (WSOL + Fee + Buy-Only)', + ['solana', 'solana-devnet'], + async () => { + await verifySuccessfulSolanaLaunch({ + configLabel: 'SOLANA DEVNET Complicated Create', + route: 'dedicated', + payload: { + network: 'devnet', + tokenMetadata: nextTokenMetadata('complex'), + economics: { + totalSupply: '12345678901', + }, + pairing: { + numeraireAddress: String(SOLANA_CONSTANTS.wsolMintAddress), + }, + pricing: { + numerairePriceUsd: 175, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 250, + marketCapEndUsd: 12500, + }, + curveFeeBps: 37, + allowBuy: true, + allowSell: false, + }, + }, + expectedCurveFeeBps: 37, + expectedAllowBuy: true, + expectedAllowSell: false, + expectedNumerairePriceUsd: 175, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); + + liveIt( + 'SOLANA DEVNET Generic Route + Idempotency Replay', + ['solana', 'solana-devnet', 'solana-defaults'], + async () => { + await verifySuccessfulSolanaLaunch({ + configLabel: 'SOLANA DEVNET Generic Route + Replay', + route: 'generic', + replayIdempotency: true, + payload: { + network: 'solanaDevnet', + tokenMetadata: nextTokenMetadata('generic'), + economics: { + totalSupply: '2500000000', + }, + pricing: { + numerairePriceUsd: 160, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 120, + marketCapEndUsd: 2400, + }, + }, + }, + expectedNumerairePriceUsd: 160, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); + + liveIt( + 'SOLANA DEVNET Random Parameters', + ['solana', 'solana-devnet', 'solana-random'], + async () => { + const totalSupply = (2_000_000_000n + BigInt(Math.floor(Math.random() * 18_000_000_000))).toString(); + const marketCapStartUsd = 75 + Math.floor(Math.random() * 425); + const marketCapEndUsd = marketCapStartUsd * (4 + Math.floor(Math.random() * 9)); + const curveFeeBps = Math.floor(Math.random() * 101); + let allowBuy = Math.random() >= 0.35; + let allowSell = Math.random() >= 0.35; + if (!allowBuy && !allowSell) { + allowBuy = true; + } + const numerairePriceUsd = 100 + Math.floor(Math.random() * 151); + + await verifySuccessfulSolanaLaunch({ + configLabel: `SOLANA DEVNET Random Parameters (${marketCapStartUsd}-${marketCapEndUsd}, fee ${curveFeeBps} bps, buy=${allowBuy}, sell=${allowSell})`, + route: 'dedicated', + payload: { + network: 'devnet', + tokenMetadata: nextTokenMetadata('random'), + economics: { + totalSupply, + }, + pricing: { + numerairePriceUsd, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd, + marketCapEndUsd, + }, + curveFeeBps, + allowBuy, + allowSell, + }, + }, + expectedCurveFeeBps: curveFeeBps, + expectedAllowBuy: allowBuy, + expectedAllowSell: allowSell, + expectedNumerairePriceUsd: numerairePriceUsd, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); + + liveIt( + 'SOLANA Generic Route Rejects Short Alias', + ['solana', 'solana-devnet', 'solana-failing'], + async () => { + await verifyFailedSolanaLaunch({ + route: 'generic', + expectedStatusCode: 422, + expectedCode: 'INVALID_REQUEST', + payload: { + network: 'devnet', + tokenMetadata: nextTokenMetadata('badalias'), + economics: { + totalSupply: '1000000000', + }, + pricing: { + numerairePriceUsd: 150, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + } as unknown as SolanaLivePayload, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); + + liveIt( + 'SOLANA Dedicated Route Rejects Mainnet Beta Execution', + ['solana', 'solana-devnet', 'solana-failing'], + async () => { + await verifyFailedSolanaLaunch({ + route: 'dedicated', + expectedStatusCode: 501, + expectedCode: 'SOLANA_NETWORK_UNSUPPORTED', + payload: { + network: 'mainnet-beta', + tokenMetadata: nextTokenMetadata('mainnet'), + economics: { + totalSupply: '1000000000', + }, + pricing: { + numerairePriceUsd: 150, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); + + liveIt( + 'SOLANA Dedicated Route Rejects Non-WSOL Numeraire', + ['solana', 'solana-devnet', 'solana-failing'], + async () => { + await verifyFailedSolanaLaunch({ + route: 'dedicated', + expectedStatusCode: 422, + expectedCode: 'SOLANA_NUMERAIRE_UNSUPPORTED', + payload: { + network: 'devnet', + tokenMetadata: nextTokenMetadata('badquote'), + economics: { + totalSupply: '1000000000', + }, + pairing: { + numeraireAddress: SOLANA_NON_WSOL_ADDRESS, + }, + pricing: { + numerairePriceUsd: 150, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); + + liveIt( + 'SOLANA Dedicated Route Rejects Invalid Curve Range', + ['solana', 'solana-devnet', 'solana-failing'], + async () => { + await verifyFailedSolanaLaunch({ + route: 'dedicated', + expectedStatusCode: 422, + expectedCode: 'SOLANA_INVALID_CURVE', + payload: { + network: 'devnet', + tokenMetadata: nextTokenMetadata('badcurve'), + economics: { + totalSupply: '1000000000', + }, + pricing: { + numerairePriceUsd: 150, + }, + governance: false, + migration: { + type: 'noOp', + }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 1000, + marketCapEndUsd: 100, + }, + }, + }, + }); + }, + SOLANA_LIVE_TIMEOUT_MS, + ); +}; diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index c9b14db..4136e36 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -27,6 +27,19 @@ const resetEnv = (overrides: Record = {}): void => { 'IDEMPOTENCY_REDIS_LOCK_TTL_MS', 'IDEMPOTENCY_REDIS_LOCK_REFRESH_MS', 'PRICE_COINGECKO_ASSET_ID', + 'SOLANA_ENABLED', + 'SOLANA_DEFAULT_NETWORK', + 'SOLANA_DEVNET_RPC_URL', + 'SOLANA_DEVNET_WS_URL', + 'SOLANA_MAINNET_BETA_RPC_URL', + 'SOLANA_MAINNET_BETA_WS_URL', + 'SOLANA_KEYPAIR', + 'SOLANA_CONFIRM_TIMEOUT_MS', + 'SOLANA_DEVNET_USE_ALT', + 'SOLANA_DEVNET_ALT_ADDRESS', + 'SOLANA_PRICE_MODE', + 'SOLANA_FIXED_NUMERAIRE_PRICE_USD', + 'SOLANA_COINGECKO_ASSET_ID', ]; for (const key of keysToUnset) { @@ -84,6 +97,17 @@ describe('shared-environment config guardrails', () => { expect(config.pricing.coingeckoAssetId).toBe('usd-coin'); }); + it('loads canonical Solana defaults and Solana CoinGecko asset id', () => { + resetEnv(); + + const config = loadConfig(); + + expect(config.solana.defaultNetwork).toBe('solanaDevnet'); + expect(config.solana.devnetRpcUrl).toBe('https://api.devnet.solana.com'); + expect(config.solana.devnetWsUrl).toBe('wss://api.devnet.solana.com'); + expect(config.solana.coingeckoAssetId).toBe('solana'); + }); + it('fails fast when DEFAULT_CHAIN_ID is not in typed config', () => { resetEnv({ DEFAULT_CHAIN_ID: '1', @@ -140,4 +164,38 @@ describe('shared-environment config guardrails', () => { 'IDEMPOTENCY_REDIS_LOCK_REFRESH_MS must be less than IDEMPOTENCY_REDIS_LOCK_TTL_MS', ); }); + + it('rejects invalid Solana keypair env values', () => { + resetEnv({ + SOLANA_KEYPAIR: '[1,2,3]', + }); + + expect(() => loadConfig()).toThrow( + 'SOLANA_KEYPAIR must be a JSON array containing 64 secret-key bytes', + ); + }); + + it('fails fast when Solana is enabled without a keypair', () => { + resetEnv({ + SOLANA_ENABLED: 'true', + SOLANA_DEVNET_RPC_URL: 'http://127.0.0.1:8899', + SOLANA_DEVNET_WS_URL: 'ws://127.0.0.1:8900', + }); + + expect(() => loadConfig()).toThrow('SOLANA_KEYPAIR is required when SOLANA_ENABLED=true'); + }); + + it('fails fast when fixed Solana pricing is enabled without a fixed price', () => { + resetEnv({ + SOLANA_ENABLED: 'true', + SOLANA_DEVNET_RPC_URL: 'http://127.0.0.1:8899', + SOLANA_DEVNET_WS_URL: 'ws://127.0.0.1:8900', + SOLANA_KEYPAIR: JSON.stringify(Array.from({ length: 64 }, (_, index) => index)), + SOLANA_PRICE_MODE: 'fixed', + }); + + expect(() => loadConfig()).toThrow( + 'SOLANA_FIXED_NUMERAIRE_PRICE_USD is required when SOLANA_PRICE_MODE=fixed', + ); + }); }); diff --git a/tests/unit/fee-beneficiaries.test.ts b/tests/unit/fee-beneficiaries.test.ts index 5625399..6f40913 100644 --- a/tests/unit/fee-beneficiaries.test.ts +++ b/tests/unit/fee-beneficiaries.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { WAD } from '@whetstone-research/doppler-sdk'; +import { WAD } from '@whetstone-research/doppler-sdk/evm'; import { normalizeFeeBeneficiaries } from '../../src/modules/auctions/multicurve/mapper'; import type { CreateLaunchRequestInput } from '../../src/modules/launches/schema'; diff --git a/tests/unit/idempotency-store.test.ts b/tests/unit/idempotency-store.test.ts index 601c1ab..ac1cd02 100644 --- a/tests/unit/idempotency-store.test.ts +++ b/tests/unit/idempotency-store.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; +import { AppError } from '../../src/core/errors'; import type { CreateLaunchResponse } from '../../src/core/types'; import { FileIdempotencyStore, @@ -60,6 +61,20 @@ const buildResponse = (txHash: `0x${string}`): CreateLaunchResponse => ({ }, }); +const buildSolanaInDoubtError = () => + new AppError( + 409, + 'SOLANA_LAUNCH_IN_DOUBT', + 'Solana launch confirmation is in doubt', + { + launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', + signature: + '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', + explorerUrl: + 'https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet', + }, + ); + class FakeRedisClient implements IdempotencyRedisClient { private readonly store = new Map(); @@ -178,7 +193,9 @@ describe('idempotency store', () => { expect(first.replayed).toBe(false); expect(second.replayed).toBe(true); - expect(second.response.txHash).toBe(first.response.txHash); + expect((second.response as CreateLaunchResponse).txHash).toBe( + (first.response as CreateLaunchResponse).txHash, + ); }); it('file backend rejects same key with different payload', async () => { @@ -229,7 +246,9 @@ describe('idempotency store', () => { expect(first.replayed).toBe(false); expect(second.replayed).toBe(true); - expect(second.response.txHash).toBe(first.response.txHash); + expect((second.response as CreateLaunchResponse).txHash).toBe( + (first.response as CreateLaunchResponse).txHash, + ); }); it('redis backend rejects same key with different payload', async () => { @@ -295,7 +314,9 @@ describe('idempotency store', () => { expect(actionExecutions).toBe(1); expect(first.replayed).toBe(false); expect(second.replayed).toBe(true); - expect(second.response.txHash).toBe(first.response.txHash); + expect((second.response as CreateLaunchResponse).txHash).toBe( + (first.response as CreateLaunchResponse).txHash, + ); }); it('redis lock heartbeat prevents duplicate execution after lock TTL window', async () => { @@ -337,7 +358,9 @@ describe('idempotency store', () => { expect(actionExecutions).toBe(1); expect(first.replayed).toBe(false); expect(second.replayed).toBe(true); - expect(second.response.txHash).toBe(first.response.txHash); + expect((second.response as CreateLaunchResponse).txHash).toBe( + (first.response as CreateLaunchResponse).txHash, + ); }); it('redis backend fails closed when recovering in-progress key without lock', async () => { @@ -406,4 +429,66 @@ describe('idempotency store', () => { expect(actionExecutions).toBe(1); }); + + it('file backend persists Solana in-doubt results and fails closed on retry', async () => { + const runId = (Date.now() + 2).toString(); + const store = new FileIdempotencyStore({ + enabled: true, + ttlMs: 100_000, + path: `.test-results/idempotency-unit-test-solana-${runId}.json`, + }); + + await expect( + store.execute('solana-in-doubt', samplePayload as any, async () => { + throw buildSolanaInDoubtError(); + }), + ).rejects.toMatchObject({ + code: 'SOLANA_LAUNCH_IN_DOUBT', + statusCode: 409, + }); + + await expect( + store.execute('solana-in-doubt', samplePayload as any, async () => { + throw new Error('should not be called'); + }), + ).rejects.toMatchObject({ + code: 'SOLANA_LAUNCH_IN_DOUBT', + statusCode: 409, + details: { + launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', + }, + }); + }); + + it('redis backend persists Solana in-doubt results and fails closed on retry', async () => { + const redis = new FakeRedisClient(); + const store = new RedisIdempotencyStore({ + enabled: true, + ttlMs: 100_000, + redis, + keyPrefix: 'test', + }); + + await expect( + store.execute('solana-in-doubt', samplePayload as any, async () => { + throw buildSolanaInDoubtError(); + }), + ).rejects.toMatchObject({ + code: 'SOLANA_LAUNCH_IN_DOUBT', + statusCode: 409, + }); + + await expect( + store.execute('solana-in-doubt', samplePayload as any, async () => { + throw new Error('should not be called'); + }), + ).rejects.toMatchObject({ + code: 'SOLANA_LAUNCH_IN_DOUBT', + statusCode: 409, + details: { + signature: + '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', + }, + }); + }); }); diff --git a/tests/unit/live-readiness-check.test.ts b/tests/unit/live-readiness-check.test.ts index 8418e83..5ef4b78 100644 --- a/tests/unit/live-readiness-check.test.ts +++ b/tests/unit/live-readiness-check.test.ts @@ -2,8 +2,14 @@ import { parseEther } from 'viem'; import { describe, expect, it } from 'vitest'; import { buildLiveBalanceRequirement, + buildLiveSolanaBalanceRequirement, DEFAULT_LIVE_ESTIMATED_OVERHEAD_ETH, + DEFAULT_LIVE_ESTIMATED_OVERHEAD_SOL, DEFAULT_LIVE_ESTIMATED_TX_COST_ETH, + DEFAULT_LIVE_ESTIMATED_TX_COST_SOL, + estimateLiveSolanaLaunchCount, + formatSolAmount, + isSolanaLiveFilter, estimateLiveLaunchCount, } from '../live/readiness-check'; @@ -25,6 +31,17 @@ describe('live readiness check', () => { expect(estimateLiveLaunchCount('unknown-filter')).toBe(19); }); + it('estimates Solana launch counts and filter detection', () => { + expect(estimateLiveSolanaLaunchCount('solana')).toBe(4); + expect(estimateLiveSolanaLaunchCount('solana-devnet')).toBe(4); + expect(estimateLiveSolanaLaunchCount('solana-defaults')).toBe(2); + expect(estimateLiveSolanaLaunchCount('solana-random')).toBe(1); + expect(estimateLiveSolanaLaunchCount('solana-failing')).toBe(0); + expect(isSolanaLiveFilter('solana')).toBe(true); + expect(isSolanaLiveFilter('solana-devnet')).toBe(true); + expect(isSolanaLiveFilter('multicurve')).toBe(false); + }); + it('computes estimated required wei using defaults', () => { const requirement = buildLiveBalanceRequirement({ liveFilter: 'multicurve-defaults', @@ -56,6 +73,31 @@ describe('live readiness check', () => { expect(requirement!.reason).toContain('LIVE_TEST_MIN_BALANCE_ETH=0.25'); }); + it('computes estimated required lamports using Solana defaults', () => { + const requirement = buildLiveSolanaBalanceRequirement({ + liveFilter: 'solana-defaults', + }); + + expect(requirement).not.toBeNull(); + expect(formatSolAmount(requirement!.requiredLamports)).toBe( + formatSolAmount(60_000_000n), + ); + expect(requirement!.reason).toContain( + `estimate: 2 launch tx * ${DEFAULT_LIVE_ESTIMATED_TX_COST_SOL} SOL + ${DEFAULT_LIVE_ESTIMATED_OVERHEAD_SOL} SOL overhead`, + ); + }); + + it('honors explicit minimum Solana balance override', () => { + const requirement = buildLiveSolanaBalanceRequirement({ + liveFilter: 'solana-failing', + minBalanceSol: '0.5', + }); + + expect(requirement).not.toBeNull(); + expect(formatSolAmount(requirement!.requiredLamports)).toBe('0.5'); + expect(requirement!.reason).toContain('LIVE_TEST_MIN_BALANCE_SOL=0.5'); + }); + it('throws for invalid minimum balance values', () => { expect(() => buildLiveBalanceRequirement({ @@ -64,4 +106,13 @@ describe('live readiness check', () => { }), ).toThrow(/LIVE_TEST_MIN_BALANCE_ETH/); }); + + it('throws for invalid minimum Solana balance values', () => { + expect(() => + buildLiveSolanaBalanceRequirement({ + liveFilter: 'solana', + minBalanceSol: 'not-a-number', + }), + ).toThrow(/LIVE_TEST_MIN_BALANCE_SOL/); + }); }); diff --git a/tests/unit/pricing-resolution.test.ts b/tests/unit/pricing-resolution.test.ts index a1914bc..7951b25 100644 --- a/tests/unit/pricing-resolution.test.ts +++ b/tests/unit/pricing-resolution.test.ts @@ -37,6 +37,16 @@ const baseConfig: AppConfig = { cacheTtlMs: 1000, coingeckoAssetId: 'ethereum', }, + solana: { + enabled: false, + defaultNetwork: 'solanaDevnet', + devnetRpcUrl: 'http://127.0.0.1:8899', + devnetWsUrl: 'ws://127.0.0.1:8900', + confirmTimeoutMs: 60_000, + useAlt: false, + priceMode: 'required', + coingeckoAssetId: 'solana', + }, chains: { 84532: { chainId: 84532, diff --git a/tests/unit/solana.test.ts b/tests/unit/solana.test.ts new file mode 100644 index 0000000..caf1612 --- /dev/null +++ b/tests/unit/solana.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AppConfig } from '../../src/core/config'; +import { + SOLANA_CONSTANTS, + SolanaLaunchService, + dedicatedSolanaCreateLaunchRequestSchema, + deriveSolanaCurveConfig, + deriveSolanaLaunchSeed, + genericSolanaCreateLaunchRequestSchema, + normalizeDedicatedSolanaCreateRequest, +} from '../../src/modules/launches/solana'; + +const buildConfig = (solanaOverrides: Partial = {}): AppConfig => ({ + port: 3000, + deploymentMode: 'standalone', + apiKey: 'test-key', + apiKeys: ['test-key'], + defaultChainId: 84532, + privateKey: '0x59c6995e998f97a5a0044966f0945386f3f6f3d1063f4042afe30de8f34a4c9e', + logLevel: 'silent', + readyRpcTimeoutMs: 1000, + corsOrigins: [], + rateLimit: { + max: 100, + timeWindowMs: 60_000, + }, + redis: { + keyPrefix: 'doppler-api-test', + }, + idempotency: { + enabled: true, + backend: 'file', + requireKey: false, + ttlMs: 86_400_000, + storePath: '.test-results/test-idempotency.json', + redisLockTtlMs: 900_000, + redisLockRefreshMs: 300_000, + }, + pricing: { + enabled: true, + provider: 'coingecko', + baseUrl: 'https://api.coingecko.com/api/v3', + timeoutMs: 1000, + cacheTtlMs: 1000, + coingeckoAssetId: 'ethereum', + }, + solana: { + enabled: true, + defaultNetwork: 'solanaDevnet', + devnetRpcUrl: 'http://127.0.0.1:8899', + devnetWsUrl: 'ws://127.0.0.1:8900', + confirmTimeoutMs: 60_000, + useAlt: false, + priceMode: 'required', + coingeckoAssetId: 'solana', + ...solanaOverrides, + }, + chains: { + 84532: { + chainId: 84532, + rpcUrl: 'http://localhost:8545', + defaultNumeraireAddress: '0x4200000000000000000000000000000000000006', + auctionTypes: ['multicurve'], + migrationModes: ['noOp'], + governanceModes: ['noOp'], + governanceEnabled: false, + }, + }, +}); + +describe('Solana launch helpers', () => { + it('normalizes dedicated route network aliases to canonical Solana network names', () => { + const parsed = dedicatedSolanaCreateLaunchRequestSchema.parse({ + network: 'devnet', + tokenMetadata: { name: 'Token', symbol: 'TOK', tokenURI: 'ipfs://token' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }); + + expect(normalizeDedicatedSolanaCreateRequest(parsed, 'solanaMainnetBeta')).toMatchObject({ + network: 'solanaDevnet', + }); + }); + + it('defaults dedicated Solana requests to the configured canonical network', () => { + const parsed = dedicatedSolanaCreateLaunchRequestSchema.parse({ + tokenMetadata: { name: 'Token', symbol: 'TOK', tokenURI: 'ipfs://token' }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }); + + expect(normalizeDedicatedSolanaCreateRequest(parsed, 'solanaMainnetBeta')).toMatchObject({ + network: 'solanaMainnetBeta', + }); + }); + + it('rejects unsupported Solana fields instead of ignoring them', () => { + expect(() => + genericSolanaCreateLaunchRequestSchema.parse({ + network: 'solanaDevnet', + tokenMetadata: { name: 'Token', symbol: 'TOK', tokenURI: 'ipfs://token' }, + economics: { + totalSupply: '1000', + tokensForSale: '100', + }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }), + ).toThrow(/unrecognized key/i); + }); + + it('rejects governance, migration, and auction shapes that are outside the Solana profile', () => { + expect(() => + genericSolanaCreateLaunchRequestSchema.parse({ + network: 'solanaDevnet', + tokenMetadata: { name: 'Token', symbol: 'TOK', tokenURI: 'ipfs://token' }, + economics: { totalSupply: '1000' }, + governance: true, + migration: { type: 'uniswapV2' }, + auction: { + type: 'multicurve', + curveConfig: { + type: 'preset', + presets: ['low'], + }, + }, + }), + ).toThrow(); + }); + + it('enforces metadata bounds and u64 total-supply limits', () => { + expect(() => + genericSolanaCreateLaunchRequestSchema.parse({ + network: 'solanaDevnet', + tokenMetadata: { + name: 'a'.repeat(33), + symbol: 'TOK', + tokenURI: 'ipfs://token', + }, + economics: { totalSupply: '1000' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }), + ).toThrow(); + + expect(() => + genericSolanaCreateLaunchRequestSchema.parse({ + network: 'solanaDevnet', + tokenMetadata: { name: 'Token', symbol: 'TOK', tokenURI: 'ipfs://token' }, + economics: { totalSupply: '18446744073709551616' }, + governance: false, + migration: { type: 'noOp' }, + auction: { + type: 'xyk', + curveConfig: { + type: 'range', + marketCapStartUsd: 100, + marketCapEndUsd: 1000, + }, + }, + }), + ).toThrow(); + }); + + it('derives deterministic Solana launch seeds from idempotency keys', () => { + const first = deriveSolanaLaunchSeed('solanaDevnet', 'same-key'); + const second = deriveSolanaLaunchSeed('solanaDevnet', 'same-key'); + const third = deriveSolanaLaunchSeed('solanaMainnetBeta', 'same-key'); + + expect(Buffer.from(first).toString('hex')).toBe(Buffer.from(second).toString('hex')); + expect(Buffer.from(first).toString('hex')).not.toBe(Buffer.from(third).toString('hex')); + }); + + it('derives bounded Solana XYK virtual reserves from market-cap ranges', () => { + const derived = deriveSolanaCurveConfig({ + totalSupply: 1_000_000_000n, + numerairePriceUsd: 100, + marketCapStartUsd: 100, + marketCapEndUsd: 1_000, + }); + + expect(derived.curveVirtualBase).toBeGreaterThan(0n); + expect(derived.curveVirtualQuote).toBeGreaterThan(0n); + }); + + it('rejects invalid Solana curve ranges', () => { + expect(() => + deriveSolanaCurveConfig({ + totalSupply: 1_000_000_000n, + numerairePriceUsd: 100, + marketCapStartUsd: 1_000, + marketCapEndUsd: 100, + }), + ).toThrow(/marketCapEndUsd must be greater than marketCapStartUsd/i); + }); + + it('resolves Solana numeraire price by request override, fixed env price, then CoinGecko', async () => { + const pricingService = { + getUsdPriceByAssetId: vi.fn().mockResolvedValue(321), + } as any; + + const serviceWithFixedPrice = new SolanaLaunchService({ + config: buildConfig({ + fixedNumerairePriceUsd: 123, + priceMode: 'coingecko', + }), + pricingService, + }); + + const overridePrice = await (serviceWithFixedPrice as any).resolveNumerairePriceUsd( + { + pricing: { numerairePriceUsd: 456 }, + }, + SOLANA_CONSTANTS.wsolMintAddress, + ); + expect(overridePrice).toBe(456); + expect(pricingService.getUsdPriceByAssetId).not.toHaveBeenCalled(); + + const fixedPrice = await (serviceWithFixedPrice as any).resolveNumerairePriceUsd( + {}, + SOLANA_CONSTANTS.wsolMintAddress, + ); + expect(fixedPrice).toBe(123); + expect(pricingService.getUsdPriceByAssetId).not.toHaveBeenCalled(); + + const serviceWithCoingecko = new SolanaLaunchService({ + config: buildConfig({ + fixedNumerairePriceUsd: undefined, + priceMode: 'coingecko', + }), + pricingService, + }); + const coingeckoPrice = await (serviceWithCoingecko as any).resolveNumerairePriceUsd( + {}, + SOLANA_CONSTANTS.wsolMintAddress, + ); + + expect(coingeckoPrice).toBe(321); + expect(pricingService.getUsdPriceByAssetId).toHaveBeenCalledWith('solana'); + }); + + it('fails closed when Solana price resolution is required but unavailable', async () => { + const service = new SolanaLaunchService({ + config: buildConfig({ + fixedNumerairePriceUsd: undefined, + priceMode: 'required', + }), + pricingService: { + getUsdPriceByAssetId: vi.fn(), + } as any, + }); + + await expect( + (service as any).resolveNumerairePriceUsd({}, SOLANA_CONSTANTS.wsolMintAddress), + ).rejects.toMatchObject({ + statusCode: 422, + code: 'SOLANA_NUMERAIRE_PRICE_REQUIRED', + }); + }); +}); From bfcd550e3e180b22838b788fef34f7d71fc86a4b Mon Sep 17 00:00:00 2001 From: Rusty Date: Tue, 21 Apr 2026 14:48:38 -0700 Subject: [PATCH 4/4] format merged solana devnet changes --- docs/openapi.yaml | 3 +-- src/app/routes/capabilities.get.ts | 8 +++--- src/app/routes/solana-launches.post.ts | 4 +-- src/core/config.ts | 4 +-- src/infra/idempotency/store.ts | 28 ++++++++++---------- src/modules/launches/service.ts | 5 +--- src/modules/launches/solana.ts | 27 +++++++------------ tests/integration/multichain-routing.test.ts | 7 ++++- tests/integration/test-server.ts | 9 +++++-- tests/live/readiness-check.ts | 3 +-- tests/live/scenarios/solana.live.ts | 27 +++++++++++++------ tests/unit/idempotency-store.test.ts | 19 +++++-------- tests/unit/live-readiness-check.test.ts | 4 +-- 13 files changed, 73 insertions(+), 75 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 141968f..b1b26a7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1044,8 +1044,7 @@ components: $ref: '#/components/schemas/SolanaAddress' quoteVaultAddress: $ref: '#/components/schemas/SolanaAddress' - required: - [tokenAddress, launchAuthorityAddress, baseVaultAddress, quoteVaultAddress] + required: [tokenAddress, launchAuthorityAddress, baseVaultAddress, quoteVaultAddress] effectiveConfig: type: object properties: diff --git a/src/app/routes/capabilities.get.ts b/src/app/routes/capabilities.get.ts index 900df9e..a4097be 100644 --- a/src/app/routes/capabilities.get.ts +++ b/src/app/routes/capabilities.get.ts @@ -48,10 +48,10 @@ export const registerCapabilitiesRoute = async ( multicurveInitializers: chain.config.auctionTypes.includes('multicurve') ? resolveMulticurveInitializers(chain) : [], - migrationModes: chain.config.migrationModes, - governanceModes: chain.config.governanceModes, - governanceEnabled: chain.config.governanceEnabled, - })), + migrationModes: chain.config.migrationModes, + governanceModes: chain.config.governanceModes, + governanceEnabled: chain.config.governanceEnabled, + })), solana: { enabled: config.solana.enabled, supportedNetworks: config.solana.enabled ? ['solanaDevnet'] : [], diff --git a/src/app/routes/solana-launches.post.ts b/src/app/routes/solana-launches.post.ts index bdecfd9..caa035e 100644 --- a/src/app/routes/solana-launches.post.ts +++ b/src/app/routes/solana-launches.post.ts @@ -1,9 +1,7 @@ import type { FastifyInstance } from 'fastify'; import type { SolanaNetwork } from '../../core/types'; -import { - parseDedicatedSolanaCreateLaunchRequest, -} from '../../modules/launches/solana'; +import { parseDedicatedSolanaCreateLaunchRequest } from '../../modules/launches/solana'; import type { LaunchService } from '../../modules/launches/service'; export const registerCreateSolanaLaunchRoute = async ( diff --git a/src/core/config.ts b/src/core/config.ts index b347c46..97b4921 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -186,9 +186,7 @@ const parseSolanaDefaultNetwork = ( ); }; -const parseSolanaPriceMode = ( - value: string | undefined, -): 'required' | 'fixed' | 'coingecko' => { +const parseSolanaPriceMode = (value: string | undefined): 'required' | 'fixed' | 'coingecko' => { const raw = value?.trim().toLowerCase() || 'required'; if (raw === 'required' || raw === 'fixed' || raw === 'coingecko') { return raw; diff --git a/src/infra/idempotency/store.ts b/src/infra/idempotency/store.ts index 8af5d82..07581c1 100644 --- a/src/infra/idempotency/store.ts +++ b/src/infra/idempotency/store.ts @@ -222,7 +222,12 @@ export class FileIdempotencyStore implements IdempotencyStore { throwKeyReuseMismatch('Idempotency key was already used with a different request payload'); } if (existing.state === 'in_doubt') { - throw new AppError(409, existing.error.code, existing.error.message, existing.error.details); + throw new AppError( + 409, + existing.error.code, + existing.error.message, + existing.error.details, + ); } return { response: existing.response, replayed: true }; } @@ -581,18 +586,18 @@ export class RedisIdempotencyStore implements IdempotencyStore { } if (existing.state === 'in_doubt') { - throw new AppError(409, existing.error.code, existing.error.message, existing.error.details); + throw new AppError( + 409, + existing.error.code, + existing.error.message, + existing.error.details, + ); } const replay = await this.waitForRecordOrUnlock(key, payloadHash); if (replay) { if (replay.state === 'in_doubt') { - throw new AppError( - 409, - replay.error.code, - replay.error.message, - replay.error.details, - ); + throw new AppError(409, replay.error.code, replay.error.message, replay.error.details); } return { response: replay.response, replayed: true }; } @@ -624,12 +629,7 @@ export class RedisIdempotencyStore implements IdempotencyStore { const replay = await this.waitForRecordOrUnlock(key, payloadHash); if (replay) { if (replay.state === 'in_doubt') { - throw new AppError( - 409, - replay.error.code, - replay.error.message, - replay.error.details, - ); + throw new AppError(409, replay.error.code, replay.error.message, replay.error.details); } return { response: replay.response, replayed: true }; } diff --git a/src/modules/launches/service.ts b/src/modules/launches/service.ts index 4151e6d..a4fe84b 100644 --- a/src/modules/launches/service.ts +++ b/src/modules/launches/service.ts @@ -15,10 +15,7 @@ import type { import { createDynamicLaunch } from '../auctions/dynamic/service'; import { createMulticurveLaunch } from '../auctions/multicurve/service'; import { createStaticLaunch } from '../auctions/static/service'; -import { - SolanaLaunchService, - type CreateSolanaLaunchRequestInput, -} from './solana'; +import { SolanaLaunchService, type CreateSolanaLaunchRequestInput } from './solana'; interface LaunchServiceDeps { chainRegistry: ChainRegistry; diff --git a/src/modules/launches/solana.ts b/src/modules/launches/solana.ts index 7c06670..7193dfd 100644 --- a/src/modules/launches/solana.ts +++ b/src/modules/launches/solana.ts @@ -24,10 +24,7 @@ import { z } from 'zod'; import type { AppConfig } from '../../core/config'; import { AppError } from '../../core/errors'; -import type { - CreateSolanaLaunchResponse, - SolanaNetwork, -} from '../../core/types'; +import type { CreateSolanaLaunchResponse, SolanaNetwork } from '../../core/types'; import type { PricingService } from '../pricing/service'; const U64_MAX = 18_446_744_073_709_551_615n; @@ -44,16 +41,14 @@ const positiveFiniteNumberSchema = z .number() .refine((value) => Number.isFinite(value) && value > 0, 'must be a positive number'); -const solanaAddressSchema = z - .string() - .refine((value) => { - try { - address(value); - return true; - } catch { - return false; - } - }, 'must be a valid Solana address'); +const solanaAddressSchema = z.string().refine((value) => { + try { + address(value); + return true; + } catch { + return false; + } +}, 'must be a valid Solana address'); const u64StringSchema = z .string() @@ -227,9 +222,7 @@ export const deriveSolanaLaunchSeed = ( return randomBytes(32); } - return createHash('sha256') - .update(`solana-launch:${network}:${idempotencyKey}`) - .digest(); + return createHash('sha256').update(`solana-launch:${network}:${idempotencyKey}`).digest(); }; const buildExplorerUrl = (network: SolanaNetwork, signature: string): string => { diff --git a/tests/integration/multichain-routing.test.ts b/tests/integration/multichain-routing.test.ts index 917ddf7..573788a 100644 --- a/tests/integration/multichain-routing.test.ts +++ b/tests/integration/multichain-routing.test.ts @@ -272,7 +272,12 @@ describe('GET /v1/capabilities', () => { getProviderName: () => 'coingecko', } as any, solanaLaunchService: { - getReadiness: async () => ({ enabled: true, ok: true, network: 'solanaDevnet', checks: [] }), + getReadiness: async () => ({ + enabled: true, + ok: true, + network: 'solanaDevnet', + checks: [], + }), createLaunch: async () => { throw new Error('not used'); }, diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 26fb97e..8a3989f 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -207,7 +207,8 @@ export const buildTestServer = async (options: BuildTestServerOptions = {}) => { return { launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', network: payload?.network ?? 'solanaDevnet', - signature: '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', + signature: + '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', explorerUrl: 'https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet', predicted: { @@ -321,7 +322,11 @@ export const buildTestServer = async (options: BuildTestServerOptions = {}) => { createLaunch: async (payload?: { governance?: unknown }) => { return resolveLaunchResponse(payload); }, - createLaunchWithIdempotency: async (payload?: { governance?: unknown; input?: unknown; idempotencyKey?: string }) => { + createLaunchWithIdempotency: async (payload?: { + governance?: unknown; + input?: unknown; + idempotencyKey?: string; + }) => { const key = payload?.idempotencyKey?.trim(); const input = payload?.input; diff --git a/tests/live/readiness-check.ts b/tests/live/readiness-check.ts index 0b0ee88..61383b8 100644 --- a/tests/live/readiness-check.ts +++ b/tests/live/readiness-check.ts @@ -148,8 +148,7 @@ export const buildLiveSolanaBalanceRequirement = (args: { return null; } - const estimatedTxCostSol = - args.estimatedTxCostSol?.trim() || DEFAULT_LIVE_ESTIMATED_TX_COST_SOL; + const estimatedTxCostSol = args.estimatedTxCostSol?.trim() || DEFAULT_LIVE_ESTIMATED_TX_COST_SOL; const estimatedOverheadSol = args.estimatedOverheadSol?.trim() || DEFAULT_LIVE_ESTIMATED_OVERHEAD_SOL; const estimatedTxCostLamports = parseSolAmount( diff --git a/tests/live/scenarios/solana.live.ts b/tests/live/scenarios/solana.live.ts index 80edda1..0a787e9 100644 --- a/tests/live/scenarios/solana.live.ts +++ b/tests/live/scenarios/solana.live.ts @@ -32,9 +32,7 @@ const SOLANA_ACCOUNT_COMMITMENT = { commitment: 'confirmed' as const }; const SOLANA_NON_WSOL_ADDRESS = '11111111111111111111111111111111'; type SolanaLiveRoute = 'dedicated' | 'generic'; -type SolanaLivePayload = - | DedicatedSolanaCreateLaunchRequestInput - | CreateSolanaLaunchRequestInput; +type SolanaLivePayload = DedicatedSolanaCreateLaunchRequestInput | CreateSolanaLaunchRequestInput; const sleep = (ms: number) => new Promise((resolve) => { @@ -45,7 +43,10 @@ const nextTokenMetadata = (prefix: string) => { const suffix = randomBytes(3).toString('hex').toUpperCase(); return { name: `SOL ${prefix} ${suffix}`.slice(0, 32), - symbol: `${prefix.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 4)}${suffix}`.slice(0, 10), + symbol: `${prefix + .replace(/[^a-z0-9]/gi, '') + .toUpperCase() + .slice(0, 4)}${suffix}`.slice(0, 10), tokenURI: `ipfs://live-solana/${prefix.toLowerCase()}/${Date.now()}-${suffix.toLowerCase()}`, }; }; @@ -62,7 +63,11 @@ const assertSolanaAccountExists = async ( rpc: ReturnType, accountAddress: string, ): Promise => { - const account = await fetchEncodedAccount(rpc, address(accountAddress), SOLANA_ACCOUNT_COMMITMENT); + const account = await fetchEncodedAccount( + rpc, + address(accountAddress), + SOLANA_ACCOUNT_COMMITMENT, + ); assertAccountExists(account); }; @@ -77,7 +82,9 @@ const waitForConfirmedSignature = async ( const status = statuses.value[0]; if (status?.err) { - throw new Error(`Solana signature ${signature} failed after submission: ${JSON.stringify(status.err)}`); + throw new Error( + `Solana signature ${signature} failed after submission: ${JSON.stringify(status.err)}`, + ); } if (status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') { @@ -141,7 +148,9 @@ const verifySuccessfulSolanaLaunch = async (args: { }); if (firstResponse.statusCode !== 200) { - throw new Error(`Unexpected Solana live response (${firstResponse.statusCode}): ${firstResponse.body}`); + throw new Error( + `Unexpected Solana live response (${firstResponse.statusCode}): ${firstResponse.body}`, + ); } const body = firstResponse.json() as CreateSolanaLaunchResponse; @@ -376,7 +385,9 @@ export const registerSolanaLiveScenarios = () => { 'SOLANA DEVNET Random Parameters', ['solana', 'solana-devnet', 'solana-random'], async () => { - const totalSupply = (2_000_000_000n + BigInt(Math.floor(Math.random() * 18_000_000_000))).toString(); + const totalSupply = ( + 2_000_000_000n + BigInt(Math.floor(Math.random() * 18_000_000_000)) + ).toString(); const marketCapStartUsd = 75 + Math.floor(Math.random() * 425); const marketCapEndUsd = marketCapStartUsd * (4 + Math.floor(Math.random() * 9)); const curveFeeBps = Math.floor(Math.random() * 101); diff --git a/tests/unit/idempotency-store.test.ts b/tests/unit/idempotency-store.test.ts index ac1cd02..d575193 100644 --- a/tests/unit/idempotency-store.test.ts +++ b/tests/unit/idempotency-store.test.ts @@ -62,18 +62,13 @@ const buildResponse = (txHash: `0x${string}`): CreateLaunchResponse => ({ }); const buildSolanaInDoubtError = () => - new AppError( - 409, - 'SOLANA_LAUNCH_IN_DOUBT', - 'Solana launch confirmation is in doubt', - { - launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', - signature: - '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', - explorerUrl: - 'https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet', - }, - ); + new AppError(409, 'SOLANA_LAUNCH_IN_DOUBT', 'Solana launch confirmation is in doubt', { + launchId: '8BD7a7kU4sASQ17S1X4Lw52dQWxwM8C2Y3jD7xA8fDzP', + signature: + '5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J', + explorerUrl: + 'https://explorer.solana.com/tx/5M7wVJf4t1A6sM97CG8PcHqx6LwH7qQ6B27vZ37h7uPj7m9Yx4mQnBn1HX9gD4FVyMPRZ4Jrped1ZSmHgkmHGW4J?cluster=devnet', + }); class FakeRedisClient implements IdempotencyRedisClient { private readonly store = new Map(); diff --git a/tests/unit/live-readiness-check.test.ts b/tests/unit/live-readiness-check.test.ts index 5ef4b78..1851a0f 100644 --- a/tests/unit/live-readiness-check.test.ts +++ b/tests/unit/live-readiness-check.test.ts @@ -79,9 +79,7 @@ describe('live readiness check', () => { }); expect(requirement).not.toBeNull(); - expect(formatSolAmount(requirement!.requiredLamports)).toBe( - formatSolAmount(60_000_000n), - ); + expect(formatSolAmount(requirement!.requiredLamports)).toBe(formatSolAmount(60_000_000n)); expect(requirement!.reason).toContain( `estimate: 2 launch tx * ${DEFAULT_LIVE_ESTIMATED_TX_COST_SOL} SOL + ${DEFAULT_LIVE_ESTIMATED_OVERHEAD_SOL} SOL overhead`, );