diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index daa33b3db..55386fcf0 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -497,6 +497,7 @@ const vortex = new VortexSdk({ apiBaseUrl: process.env.VORTEX_API_URL, // sandbox or prod publicKey: process.env.VORTEX_PUBLIC_KEY, // pk_* secretKey: process.env.VORTEX_SECRET_KEY, // sk_* — server side only + networkInitializationTimeoutMs: 15_000, // lazy per-network signing RPC timeout storeEphemeralKeys: true // writes ephemerals_.json locally }); ``` diff --git a/packages/sdk/ARCHITECTURE.md b/packages/sdk/ARCHITECTURE.md index 9895fe233..f31f6283b 100644 --- a/packages/sdk/ARCHITECTURE.md +++ b/packages/sdk/ARCHITECTURE.md @@ -10,7 +10,8 @@ state or a user's wallet. - `VortexSdk.ts` is the public orchestrator. - `services/ApiService.ts` owns HTTP requests and error mapping. - `services/NetworkManager.ts` owns the RPC connections needed for ephemeral signing and - preflight balance checks. + initializes only the networks required for ephemeral signing. Quote and registration + HTTP calls do not wait for chain WebSockets. - `handlers/BrlHandler.ts`, `AlfredpayHandler.ts`, and `MykoboHandler.ts` adapt corridor-specific registration and update data to the common lifecycle. - `eip712.ts` classifies and attaches signatures for user-owned typed-data operations. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d45b3c68b..8b5e2068e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -205,13 +205,14 @@ interface VortexSdkConfig { pendulumWsUrl?: string; moonbeamWsUrl?: string; hydrationWsUrl?: string; + networkInitializationTimeoutMs?: number; autoReconnect?: boolean; alchemyApiKey?: string; storeEphemeralKeys?: boolean; } ``` -Only the base Vortex API is required. If the RPC URL's are not provided, default public ones will be used. +Only the base Vortex API is required. Chain WebSocket APIs are initialized lazily when returned unsigned transactions require them; quote and registration HTTP requests do not wait for RPC connections. If the RPC URLs are not provided, default public ones are used. `networkInitializationTimeoutMs` defaults to 15 seconds and applies independently to each required network. ### API keys diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index 658995e7e..db2347362 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -57,7 +57,6 @@ export class VortexSdk { private brlHandler: BrlHandler; private alfredpayHandler: AlfredpayHandler; private mykoboHandler: MykoboHandler; - private initializationPromise: Promise; private storeEphemeralKeys: boolean; constructor(config: VortexSdkConfig) { @@ -87,8 +86,6 @@ export class VortexSdk { this.generateEphemerals.bind(this), this.signTransactions.bind(this) ); - - this.initializationPromise = this.networkManager.waitForInitialization(); } async createQuote(request: T): Promise> { @@ -136,8 +133,6 @@ export class VortexSdk { ); } - await this.ensureInitialized(); - let rampProcess: RampProcess; let unsignedTransactions: UnsignedTx[] = []; @@ -354,10 +349,6 @@ export class VortexSdk { } } - private async ensureInitialized(): Promise { - await this.initializationPromise; - } - private async generateEphemerals(): Promise<{ ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }; accountMetas: AccountMeta[]; @@ -390,7 +381,9 @@ export class VortexSdk { evmEphemeral?: EphemeralAccount; } ): Promise { - await this.ensureInitialized(); + if (unsignedTxs.length === 0) { + return []; + } try { const signedTxs = await signUnsignedTransactions( @@ -412,7 +405,8 @@ export class VortexSdk { return signedTxs; } catch (error) { - throw new TransactionSigningError(undefined, error as Error); + const originalError = error instanceof Error ? error : new Error(String(error)); + throw new TransactionSigningError(originalError.message, originalError); } } } diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 2c99cf134..01588271f 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -384,6 +384,22 @@ export class APINotInitializedError extends VortexSdkInternalError { } } +export class NetworkApiInitializationError extends VortexSdkInternalError { + public readonly network: string; + public readonly timeoutMs: number; + + constructor(network: string, timeoutMs: number, originalError?: Error) { + const displayName = `${network.charAt(0).toUpperCase()}${network.slice(1)}`; + const message = originalError + ? `Failed to initialize ${displayName} WebSocket API: ${originalError.message}` + : `Timed out initializing ${displayName} WebSocket API after ${timeoutMs}ms`; + super(message, originalError); + this.name = "NetworkApiInitializationError"; + this.network = network; + this.timeoutMs = timeoutMs; + } +} + export class EphemeralGenerationError extends VortexSdkInternalError { constructor(network: string, originalError?: Error) { super(`Failed to generate ephemeral account for network: ${network}`, originalError); diff --git a/packages/sdk/src/services/NetworkManager.ts b/packages/sdk/src/services/NetworkManager.ts index 11828f0ba..67b2f1802 100644 --- a/packages/sdk/src/services/NetworkManager.ts +++ b/packages/sdk/src/services/NetworkManager.ts @@ -1,7 +1,10 @@ import { ApiPromise, WsProvider } from "@polkadot/api"; import { Networks } from "@vortexfi/shared"; +import { NetworkApiInitializationError } from "../errors"; import type { NetworkConfig, VortexSdkConfig } from "../types"; +const DEFAULT_NETWORK_INITIALIZATION_TIMEOUT_MS = 15_000; + const DEFAULT_NETWORKS: NetworkConfig[] = [ { name: "assethub", @@ -31,10 +34,6 @@ export class NetworkManager { constructor(private readonly config: VortexSdkConfig) {} - async waitForInitialization(): Promise { - return; - } - async getPendulumApi(): Promise { if (this.pendulumApi) { return this.pendulumApi; @@ -105,10 +104,63 @@ export class NetworkManager { throw new Error(`${network} WebSocket URL must be provided or configured.`); } - const provider = new WsProvider(wsUrl, 2_500, {}, 60_000, 102400, 10 * 60_000); - const api = await ApiPromise.create({ provider }); - await api.isReady; - return api; + const timeoutMs = this.config.networkInitializationTimeoutMs ?? DEFAULT_NETWORK_INITIALIZATION_TIMEOUT_MS; + let provider: WsProvider; + try { + provider = new WsProvider(wsUrl, 2_500, {}, 60_000, 102400, 10 * 60_000); + } catch (error) { + const originalError = error instanceof Error ? error : new Error(String(error)); + throw new NetworkApiInitializationError(network, timeoutMs, originalError); + } + + let api: ApiPromise | undefined; + let initializationFailed = false; + + const initialization = ApiPromise.create({ provider }).then(async createdApi => { + api = createdApi; + await createdApi.isReady; + + if (initializationFailed) { + this.disconnect(createdApi, provider); + } + + return createdApi; + }); + + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + initialization, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new NetworkApiInitializationError(network, timeoutMs)); + }, timeoutMs); + }) + ]); + } catch (error) { + initializationFailed = true; + this.disconnect(api, provider); + + if (error instanceof NetworkApiInitializationError) { + throw error; + } + + const originalError = error instanceof Error ? error : new Error(String(error)); + throw new NetworkApiInitializationError(network, timeoutMs, originalError); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + } + + private disconnect(api: ApiPromise | undefined, provider: WsProvider): void { + try { + const disconnect = api ? api.disconnect() : provider.disconnect(); + void Promise.resolve(disconnect).catch(() => undefined); + } catch { + // Preserve the initialization error; disconnect is best-effort cleanup. + } } private getWsUrl(network: Networks.Pendulum | Networks.Moonbeam | Networks.Hydration): string | undefined { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 47d51a404..0b5ffe0db 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -238,6 +238,12 @@ export interface VortexSdkConfig { pendulumWsUrl?: string; moonbeamWsUrl?: string; hydrationWsUrl?: string; + /** + * Maximum time to wait when a signing operation first needs a Substrate + * WebSocket API. Chain APIs are initialized lazily and independently. + * @default 15000 + */ + networkInitializationTimeoutMs?: number; autoReconnect?: boolean; alchemyApiKey?: string; storeEphemeralKeys?: boolean; diff --git a/packages/sdk/test/vortexSdk.lazyNetworks.test.ts b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts new file mode 100644 index 000000000..699225d29 --- /dev/null +++ b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + EPaymentMethod, + EphemeralAccountType, + EvmToken, + FiatToken, + Networks, + RampDirection, + type QuoteResponse, + type RegisterRampRequest, + type UnsignedTx, +} from "@vortexfi/shared"; +import { + NetworkApiInitializationError, + TransactionSigningError, +} from "../src/errors"; +import { VortexSdk } from "../src/VortexSdk"; + +const originalFetch = globalThis.fetch; +const DEAD_WEBSOCKET_URL = "ws://127.0.0.1:1"; + +const quote: QuoteResponse = { + anchorFeeFiat: "0", + anchorFeeUsd: "0", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + expiresAt: new Date("2026-01-01T00:10:00.000Z"), + feeCurrency: FiatToken.BRL, + from: EPaymentMethod.PIX, + id: "quote_1", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + networkFeeFiat: "0", + networkFeeUsd: "0", + outputAmount: "20", + outputCurrency: EvmToken.USDC, + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: EPaymentMethod.PIX, + processingFeeFiat: "0", + processingFeeUsd: "0", + rampType: RampDirection.BUY, + to: Networks.Base, + totalFeeFiat: "0", + totalFeeUsd: "0", + vortexFeeFiat: "0", + vortexFeeUsd: "0", +}; + +const offrampQuote: QuoteResponse = { + ...quote, + from: Networks.AssetHub, + inputCurrency: EvmToken.USDC, + network: Networks.AssetHub, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX, +}; + +function rampProcess( + unsignedTxs: UnsignedTx[] = [], + quoteResponse: QuoteResponse = quote +) { + return { + createdAt: "2026-01-01T00:00:00.000Z", + currentPhase: "initial", + from: quoteResponse.from, + id: "ramp_1", + inputAmount: "100", + inputCurrency: quoteResponse.inputCurrency, + network: quoteResponse.network, + outputAmount: "20", + outputCurrency: quoteResponse.outputCurrency, + paymentMethod: EPaymentMethod.PIX, + quoteId: quoteResponse.id, + to: quoteResponse.to, + type: quoteResponse.rampType, + unsignedTxs, + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function mockBackend( + requiredSigningNetwork?: Networks.Pendulum, + quoteResponse: QuoteResponse = quote +) { + const calls: string[] = []; + + globalThis.fetch = mock( + async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL( + typeof input === "string" || input instanceof URL + ? input.toString() + : input.url + ); + const method = + init?.method ?? (input instanceof Request ? input.method : "GET"); + calls.push(`${method} ${url.pathname}`); + + if (method === "POST" && url.pathname === "/v1/quotes") { + return Response.json(quoteResponse); + } + + if ( + method === "GET" && + url.pathname === `/v1/quotes/${quoteResponse.id}` + ) { + return Response.json(quoteResponse); + } + + if ( + method === "GET" && + url.pathname === "/v1/brla/getUserRemainingLimit" + ) { + return Response.json({ remainingLimit: 1_000 }); + } + + if (method === "GET" && url.pathname === "/v1/brla/validatePixKey") { + return Response.json({ valid: true }); + } + + if (method === "POST" && url.pathname === "/v1/ramp/register") { + const request = JSON.parse(String(init?.body)) as RegisterRampRequest; + const unsignedTxs: UnsignedTx[] = []; + + if (requiredSigningNetwork === Networks.Pendulum) { + const substrateSigner = request.signingAccounts.find( + (account) => account.type === EphemeralAccountType.Substrate + ); + if (!substrateSigner) { + throw new Error("Expected a Substrate signing account"); + } + unsignedTxs.push({ + meta: {}, + network: Networks.Pendulum, + nonce: 0, + phase: "fundEphemeral", + signer: substrateSigner.address, + txData: "0x00", + }); + } + + return Response.json(rampProcess(unsignedTxs, quoteResponse)); + } + + if (method === "POST" && url.pathname === "/v1/ramp/update") { + return Response.json(rampProcess([], quoteResponse)); + } + + throw new Error(`Unexpected backend request: ${method} ${url.pathname}`); + } + ) as typeof fetch; + + return calls; +} + +async function withDeadline( + promise: Promise, + timeoutMs = 2_000 +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`Test operation exceeded ${timeoutMs}ms`)), + timeoutMs + ); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +function createSdk(networkInitializationTimeoutMs = 50): VortexSdk { + return new VortexSdk({ + apiBaseUrl: "https://backend.test", + hydrationWsUrl: DEAD_WEBSOCKET_URL, + moonbeamWsUrl: DEAD_WEBSOCKET_URL, + networkInitializationTimeoutMs, + pendulumWsUrl: DEAD_WEBSOCKET_URL, + secretKey: "sk_test_user", + storeEphemeralKeys: false, + }); +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("lazy chain WebSocket initialization", () => { + test("BRL quote and registration reach the backend when no signing transactions are returned", async () => { + const calls = mockBackend(); + const sdk = createSdk(); + + const createdQuote = await withDeadline( + sdk.createQuote({ + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base, + }) + ); + const result = await withDeadline( + sdk.registerRamp(createdQuote, { destinationAddress: "0xuser" }) + ); + + expect(result.rampProcess.id).toBe("ramp_1"); + expect(calls).toContain("POST /v1/quotes"); + expect(calls).toContain("POST /v1/ramp/register"); + expect(calls).toContain("POST /v1/ramp/update"); + }); + + test("BRL offramp registration also bypasses unavailable chain WebSockets", async () => { + const calls = mockBackend(undefined, offrampQuote); + const sdk = createSdk(); + + const result = await withDeadline( + sdk.registerRamp(offrampQuote, { + pixDestination: "user@example.com", + walletAddress: "0x1234567890123456789012345678901234567890", + }) + ); + + expect(result.rampProcess.id).toBe("ramp_1"); + expect(calls).toContain("GET /v1/brla/validatePixKey"); + expect(calls).toContain("POST /v1/ramp/register"); + expect(calls).toContain("POST /v1/ramp/update"); + }); + + test("a required Pendulum signing API fails with a named, bounded timeout", async () => { + const calls = mockBackend(Networks.Pendulum); + const sdk = createSdk(40); + + let thrown: unknown; + try { + await withDeadline( + sdk.registerRamp(quote, { destinationAddress: "0xuser" }) + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(TransactionSigningError); + expect((thrown as Error).message).toContain("Pendulum WebSocket API"); + expect((thrown as Error).message).toContain("40ms"); + expect((thrown as TransactionSigningError).originalError).toBeInstanceOf( + NetworkApiInitializationError + ); + expect((thrown as TransactionSigningError).originalError).toMatchObject({ + network: Networks.Pendulum, + timeoutMs: 40, + }); + expect(calls).toContain("POST /v1/ramp/register"); + expect(calls).not.toContain("POST /v1/ramp/update"); + }); +});