From 593ef023094d03818704de9dadc043675a448761 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 18:46:43 +0200 Subject: [PATCH 1/8] feat(sdk): add storeEphemeralKeysCallback for custom key persistence Integrations wanting encrypted or vault-backed ephemeral recovery storage previously had no hook: the secrets never crossed the public SDK surface, and storeEphemeralKeys: false silently disabled the backup entirely. The callback replaces the built-in file/localStorage persistence and keeps the fail-closed registration contract. --- packages/sdk/src/VortexSdk.ts | 12 ++- packages/sdk/src/types.ts | 22 +++++ .../test/vortexSdk.storeEphemerals.test.ts | 81 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/test/vortexSdk.storeEphemerals.test.ts diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index 8bff7e7a4..b77254c45 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -44,6 +44,7 @@ import type { EurOnrampAdditionalData, ExtendedQuoteResponse, RegisterRampAdditionalData, + StoredEphemeralKey, SubmitUserTransactionsHandlers, UpdateRampAdditionalData, VortexSdkConfig @@ -59,6 +60,7 @@ export class VortexSdk { private domesticHandler: DomesticHandler; private mykoboHandler: MykoboHandler; private storeEphemeralKeys: boolean; + private storeEphemeralKeysCallback: VortexSdkConfig["storeEphemeralKeysCallback"]; private offrampFundingMode: NonNullable; constructor(config: VortexSdkConfig) { @@ -69,6 +71,7 @@ export class VortexSdk { this.apiService = new ApiService(config.apiBaseUrl, config.publicKey, config.secretKey, config.accessTokenProvider); this.networkManager = new NetworkManager(config); this.storeEphemeralKeys = config.storeEphemeralKeys ?? true; + this.storeEphemeralKeysCallback = config.storeEphemeralKeysCallback; this.offrampFundingMode = config.offrampFundingMode ?? "prefunded"; this.publicKey = config.publicKey; this.secretKey = config.secretKey; @@ -334,11 +337,11 @@ export class VortexSdk { ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }, rampId: string ): Promise { - if (!this.storeEphemeralKeys) { + if (!this.storeEphemeralKeysCallback && !this.storeEphemeralKeys) { return; } - const ephemeralItems = []; + const ephemeralItems: StoredEphemeralKey[] = []; for (const type of Object.keys(ephemerals) as EphemeralAccountType[]) { const ephemeral = ephemerals[type]; if (ephemeral) { @@ -347,6 +350,11 @@ export class VortexSdk { } } + if (this.storeEphemeralKeysCallback) { + await this.storeEphemeralKeysCallback(ephemeralItems, rampId); + return; + } + const fileName = `ephemerals_${rampId}.json`; await storeEphemeralKeys(fileName, ephemeralItems); } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f54adc52d..418c9d488 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -257,6 +257,20 @@ export interface NetworkConfig { export type OfframpFundingMode = "prefunded" | "deferred"; +/** + * One ephemeral secret persisted for recovery. The built-in storage writes an + * array of these; a configured `storeEphemeralKeysCallback` receives the same + * array. + */ +export interface StoredEphemeralKey { + address: string; + rampId: string; + secret: string; + type: EphemeralAccountType; +} + +export type StoreEphemeralKeysCallback = (keys: StoredEphemeralKey[], rampId: string) => Promise; + export type AccessTokenProvider = () => Promise; export interface VortexSdkConfig { @@ -288,6 +302,14 @@ export interface VortexSdkConfig { autoReconnect?: boolean; alchemyApiKey?: string; storeEphemeralKeys?: boolean; + /** + * Custom persistence for ephemeral recovery keys. When set, the SDK calls it + * instead of the built-in storage (JSON file in Node.js, `localStorage` in + * browsers) and `storeEphemeralKeys` has no effect. `registerRamp` awaits the + * callback and fails closed: a rejection aborts registration before + * ephemeral-owned transactions are signed. + */ + storeEphemeralKeysCallback?: StoreEphemeralKeysCallback; /** * Controls whether `registerRamp` checks that the source wallet holds the * quoted offramp amount. Deferred integrations must fund the wallet before diff --git a/packages/sdk/test/vortexSdk.storeEphemerals.test.ts b/packages/sdk/test/vortexSdk.storeEphemerals.test.ts new file mode 100644 index 000000000..d2985c90d --- /dev/null +++ b/packages/sdk/test/vortexSdk.storeEphemerals.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "fs"; +import { EphemeralAccountType } from "@vortexfi/shared"; +import type { StoredEphemeralKey, VortexSdkConfig } from "../src/types"; +import { VortexSdk } from "../src/VortexSdk"; + +const RAMP_ID = "ramp_store_test"; +const BUILT_IN_FILE = `ephemerals_${RAMP_ID}.json`; + +const ephemerals = { + [EphemeralAccountType.Substrate]: { address: "substrate-address", secret: "substrate-secret" }, + [EphemeralAccountType.EVM]: { address: "evm-address", secret: "evm-secret" }, +}; + +function makeSdk(config: Partial = {}): VortexSdk { + return new VortexSdk({ apiBaseUrl: "http://127.0.0.1:1", ...config }); +} + +describe("VortexSdk.storeEphemerals", () => { + test("passes structured items to the callback instead of the built-in storage", async () => { + const calls: Array<{ keys: StoredEphemeralKey[]; rampId: string }> = []; + const sdk = makeSdk({ + storeEphemeralKeysCallback: async (keys, rampId) => { + calls.push({ keys, rampId }); + }, + }); + + await sdk.storeEphemerals(ephemerals, RAMP_ID); + + expect(calls).toHaveLength(1); + expect(calls[0].rampId).toBe(RAMP_ID); + expect(calls[0].keys).toEqual([ + { + address: "substrate-address", + rampId: RAMP_ID, + secret: "substrate-secret", + type: EphemeralAccountType.Substrate, + }, + { + address: "evm-address", + rampId: RAMP_ID, + secret: "evm-secret", + type: EphemeralAccountType.EVM, + }, + ]); + expect(existsSync(BUILT_IN_FILE)).toBe(false); + }); + + test("invokes the callback even when storeEphemeralKeys is false", async () => { + const calls: StoredEphemeralKey[][] = []; + const sdk = makeSdk({ + storeEphemeralKeys: false, + storeEphemeralKeysCallback: async keys => { + calls.push(keys); + }, + }); + + await sdk.storeEphemerals(ephemerals, RAMP_ID); + + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(2); + }); + + test("propagates a callback rejection so registration fails closed", async () => { + const sdk = makeSdk({ + storeEphemeralKeysCallback: async () => { + throw new Error("vault unavailable"); + }, + }); + + await expect(sdk.storeEphemerals(ephemerals, RAMP_ID)).rejects.toThrow("vault unavailable"); + }); + + test("stores nothing when storage is disabled and no callback is configured", async () => { + const sdk = makeSdk({ storeEphemeralKeys: false }); + + await sdk.storeEphemerals(ephemerals, RAMP_ID); + + expect(existsSync(BUILT_IN_FILE)).toBe(false); + }); +}); From a0a6b99ada8b1159da2966b091f6b8a549aea0d7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 18:46:54 +0200 Subject: [PATCH 2/8] docs(sdk): document custom ephemeral storage callback Sync README, ARCHITECTURE, and the ephemeral-accounts security spec with the new storeEphemeralKeysCallback: built-in storage stays local-only, a configured callback shifts destination custody to the integrator, and both paths keep the fail-closed registration contract. --- .../02-signing-keys/ephemeral-accounts.md | 8 ++++---- packages/sdk/ARCHITECTURE.md | 4 +++- packages/sdk/README.md | 15 ++++++++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index c9b3555d0..988373aca 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -9,7 +9,7 @@ Ephemeral accounts are temporary blockchain accounts created per ramp operation. **Critical security property:** Ephemeral keys are generated client-side (in the SDK or frontend). The server never sees the private keys. Only the public addresses are sent to the API during ramp registration. -The SDK optionally stores ephemeral keys under `ephemerals_{rampId}.json` via the `storeEphemeralKeys` config option (defaults to `true`). Node.js writes a local JSON file; browser builds write the same plaintext JSON to same-origin localStorage. +The SDK optionally stores ephemeral keys under `ephemerals_{rampId}.json` via the `storeEphemeralKeys` config option (defaults to `true`). Node.js writes a local JSON file; browser builds write the same plaintext JSON to same-origin localStorage. Alternatively, the integrator can configure `storeEphemeralKeysCallback`; the SDK then passes the same recovery material (`{ address, rampId, secret, type }` items) to that callback instead of the built-in storage, and `storeEphemeralKeys` has no effect. This lets integrations encrypt the keys or persist them in their own vault. The frontend and dashboard store a backup of all ephemeral keypairs in separate same-origin localStorage maps, keyed by ramp ID (`rampEphemerals` for the widget and `vortex_dashboard_rampEphemerals` for the dashboard). The widget persists this through `PersistenceEffect` in `apps/frontend/src/contexts/rampState.tsx`. The dashboard writes a pending quote-keyed entry before registration, then rebinds it to the returned ramp ID. This archive is **not** cleared when ramp context, authentication, or other UI state resets. The purpose is a user-side failsafe: if a ramp fails mid-flow and the main state is wiped, the ephemeral secret keys remain recoverable from this separate localStorage entry. Once a client observes a terminal state it records `terminalObservedAt`; storage maintenance removes that entry on the first access at least 90 days later. Entries without a terminal observation are retained indefinitely (accepted as [RISK-006](../RISK-REGISTER.md)). The dashboard separately persists its serializable transfer-machine snapshot under `vortex-dashboard-transfer-state` so an onramp's server-issued payment instructions survive reload; reset/logout clears this snapshot but not the independent ephemeral archive. @@ -20,7 +20,7 @@ Frontend and SDK Substrate RPC clients are initialized lazily. Creating/importin 1. **Ephemeral private keys MUST be generated client-side** — The API MUST never generate, receive, store, or have access to ephemeral private keys. Only addresses (`accountMetas`) are sent to the API. 2. **Ephemeral accounts MUST be used for a single ramp only** — Each ramp gets fresh accounts. Reusing ephemerals across ramps creates cross-contamination risk. 3. **The API MUST validate that submitted addresses are well-formed** — Before using an ephemeral address in transactions, the API must validate the address format for the respective chain (Substrate SS58, EVM hex). -4. **Ephemeral key storage (SDK) MUST be local-only and fail closed when enabled** — The `storeEphemeralKeys` function writes to the local filesystem in Node.js or same-origin localStorage in browsers. Keys MUST NOT be transmitted to the API, logged, or stored in any remote database. After the API creates a ramp, the SDK MUST await successful persistence before signing ephemeral-owned transactions or submitting the ramp update. A storage failure MUST reject `registerRamp()` and MUST NOT be swallowed; leaving the backend registration incomplete is safer than advancing without recoverable key material. +4. **Ephemeral key storage (SDK) MUST fail closed, and built-in storage MUST be local-only** — The built-in `storeEphemeralKeys` function writes to the local filesystem in Node.js or same-origin localStorage in browsers; it MUST NOT transmit keys to the API, log them, or store them in any remote database. When the integrator configures `storeEphemeralKeysCallback`, the SDK hands the recovery material to that callback instead and custody of the destination shifts to the integrator; the SDK itself still MUST NOT transmit or log the keys. After the API creates a ramp, the SDK MUST await successful persistence (built-in or callback) before signing ephemeral-owned transactions or submitting the ramp update. A storage failure or callback rejection MUST reject `registerRamp()` and MUST NOT be swallowed; leaving the backend registration incomplete is safer than advancing without recoverable key material. 5. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. 6. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. 7. **Ephemeral addresses MUST be proven fresh on every chain the ramp will sign on, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that each submitted ephemeral address is fresh on every chain the ramp's route actually signs on. Freshness is chain-appropriate but MUST cover both nonce and balance: Substrate requires `nonce === 0 && free === 0`; EVM requires `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold a funded native balance, so a nonce-only check is insufficient). The chain set MUST be derived from the quote (`quoteToSigningNetworks`), not the full supported list: validating chains the route never touches makes an unrelated RPC outage able to block every registration (an availability-hostility the earlier all-chains rule created). The route-to-chains mapping MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap — and is pinned by `ephemeral-freshness.test.ts`. Freshness checks MUST fail closed: any RPC error rejects the registration with `503`. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce. **Known limitation:** only the native balance is checked on EVM; a nonce-0 account pre-loaded with ERC-20 tokens is not detected (enumerating tokens per chain is out of scope). @@ -49,8 +49,8 @@ Frontend and SDK Substrate RPC clients are initialized lazily. Creating/importin - [x] `createPendulumEphemeral()` and `createMoonbeamEphemeral()` are only called in the SDK/frontend, never in `apps/api` — ✅ PASS - [x] The API's ramp registration endpoint only accepts addresses (public keys), never private keys or seed phrases — ✅ PASS -- [x] `storeEphemeralKeys` writes only to a local file in Node.js or same-origin localStorage in browsers; neither path makes network calls — ✅ PASS -- [x] With SDK storage enabled, registration awaits the backup before signing and update submission; storage failures propagate and stop the client flow — ✅ PASS +- [x] Built-in `storeEphemeralKeys` writes only to a local file in Node.js or same-origin localStorage in browsers; neither path makes network calls. A configured `storeEphemeralKeysCallback` replaces both paths and its destination is integrator-owned; the SDK makes no network calls of its own with the key material — ✅ PASS +- [x] With SDK storage enabled or a custom callback configured, registration awaits persistence before signing and update submission; storage failures and callback rejections propagate and stop the client flow — ✅ PASS - [ ] Ephemeral addresses are validated for format before use in transaction construction — ❌ FAIL (F-021) - [x] No code path in the API logs or persists ephemeral private keys — ✅ PASS - [x] Each call to `generateEphemerals()` produces fresh, unique keypairs — no memoization or caching — ✅ PASS diff --git a/packages/sdk/ARCHITECTURE.md b/packages/sdk/ARCHITECTURE.md index 17ebbf911..9dd305a98 100644 --- a/packages/sdk/ARCHITECTURE.md +++ b/packages/sdk/ARCHITECTURE.md @@ -47,7 +47,9 @@ through `getUserTransactionType`, `getTypedDataToSign`, and - Ramp IDs and business correlation state belong to the integrating application. - `storeEphemeralKeys` defaults to enabled and writes a JSON file in Node.js or plain `localStorage` in browsers. Browser persistence is intentionally prototype-grade; - applications with their own secure storage may disable it and persist the material themselves. + applications with their own secure storage configure `storeEphemeralKeysCallback`, which + replaces the built-in storage and hands the recovery material to the caller. Both paths + fail closed: registration awaits persistence and rejects on failure. ## Package boundary diff --git a/packages/sdk/README.md b/packages/sdk/README.md index f67b88294..16c4f998b 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -160,11 +160,23 @@ permit. This setting is an integration sequencing option, not an account authori backend balance-check bypass. ## Core Features -- **Ephemerals abstracted**: No need to keep track of the ephemeral accounts used in the ramp process. If `storeEphemeralKeys` is enabled, keys are stored in a JSON file in Node.js or plain browser `localStorage`. Browser storage is intentionally prototype-grade and should be used only on a tightly controlled origin; disabling it also disables the SDK's recovery backup. +- **Ephemerals abstracted**: No need to keep track of the ephemeral accounts used in the ramp process. If `storeEphemeralKeys` is enabled, keys are stored in a JSON file in Node.js or plain browser `localStorage`. Browser storage is intentionally prototype-grade and should be used only on a tightly controlled origin; disabling it also disables the SDK's recovery backup. Integrations that need custom persistence (encryption, a KMS, a backend vault) can configure `storeEphemeralKeysCallback` instead. - **Stateless Design**: No internal state management - you control persistence of the rampId for status checking With the default `storeEphemeralKeys: true`, registration fails closed if the backup cannot be written. The API may already have created the ramp, but the SDK rejects `registerRamp()` before signing ephemeral-owned transactions or submitting the ramp update; it does not silently continue without recovery material. Keep the backup until the ramp is complete and its recovery window has passed. +To own the persistence yourself, pass `storeEphemeralKeysCallback`. The SDK then calls it with the recovery material — an array of `StoredEphemeralKey` (`{ address, rampId, secret, type }`) plus the ramp ID — instead of using its built-in storage, and `storeEphemeralKeys` has no effect. The same fail-closed contract applies: the SDK awaits the callback during registration, and a rejection aborts `registerRamp()` before ephemeral-owned transactions are signed. + +```typescript +const sdk = new VortexSdk({ + apiBaseUrl: "...", + secretKey: "sk_live_...", + storeEphemeralKeysCallback: async (keys, rampId) => { + await myVault.put(`ephemerals_${rampId}`, encrypt(JSON.stringify(keys))); + } +}); +``` + ## API Reference ### VortexSdk @@ -253,6 +265,7 @@ interface VortexSdkConfig { autoReconnect?: boolean; alchemyApiKey?: string; storeEphemeralKeys?: boolean; + storeEphemeralKeysCallback?: (keys: StoredEphemeralKey[], rampId: string) => Promise; offrampFundingMode?: "prefunded" | "deferred"; } ``` From e10bbfbbb023e6aafb87bd18f37fa5bdd0bd34a9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 18:47:01 +0200 Subject: [PATCH 3/8] docs(repo): correct ephemeral custody guidance in integration skill The skill claimed integrators could set storeEphemeralKeys: false and persist the keys themselves, but the secrets never crossed the public SDK surface, so that flag alone just disabled the recovery backup. Point custom-storage integrations at storeEphemeralKeysCallback. --- .agents/skills/vortex-integration/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index 737d750f0..c1a509586 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -504,7 +504,7 @@ const vortex = new VortexSdk({ }); ``` -For server processes that manage their own ephemeral key storage (e.g. HSM, encrypted DB), set `storeEphemeralKeys: false` and persist via your own mechanism. +For server processes that manage their own ephemeral key storage (e.g. HSM, encrypted DB), configure `storeEphemeralKeysCallback: async (keys, rampId) => { ... }`. The SDK calls it with the recovery material (`StoredEphemeralKey[]`: `{ address, rampId, secret, type }`) instead of writing the local file, and `storeEphemeralKeys` has no effect. A rejection aborts `registerRamp` before ephemeral-owned transactions are signed (same fail-closed contract as built-in storage). Setting only `storeEphemeralKeys: false` disables the recovery backup entirely — the secrets are not exposed anywhere else. For browser integrations, never configure `secretKey`. Resolve the current renewable Supabase token on every request: @@ -519,7 +519,7 @@ const vortex = new VortexSdk({ }); ``` -If both `secretKey` and `accessTokenProvider` are configured, the SDK uses the secret key and does not call the provider. Browser ephemeral recovery currently uses plain `localStorage`; this is intentionally prototype-grade. Set `storeEphemeralKeys: false` when the integrating application owns secure recovery storage. +If both `secretKey` and `accessTokenProvider` are configured, the SDK uses the secret key and does not call the provider. Browser ephemeral recovery currently uses plain `localStorage`; this is intentionally prototype-grade. Configure `storeEphemeralKeysCallback` when the integrating application owns secure recovery storage (or set `storeEphemeralKeys: false` to disable the backup entirely). ## REST fallback Use: From 96cfd8bc50a551526fd8ccc1b10199410b5c061e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 19:04:20 +0200 Subject: [PATCH 4/8] chore(sdk): regenerate wire-contract snapshot for storage callback Additive only: StoredEphemeralKey, StoreEphemeralKeysCallback, and the optional storeEphemeralKeysCallback config field. No existing surface changed, so live integrators are unaffected. --- docs/api/wire-contract.snapshot.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 4d77ddddd..160433a1d 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -4786,6 +4786,20 @@ StartRampError: class StartRampError { readonly status: number; } +StoreEphemeralKeysCallback: (keys: Array<{ + address: string; + rampId: string; + secret: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; +}>, rampId: string) => Promise + +StoredEphemeralKey: { + address: string; + rampId: string; + secret: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; +} + SubaccountNotFoundError: class SubaccountNotFoundError { constructor(); readonly code?: string; @@ -5508,6 +5522,12 @@ VortexSdk: class VortexSdk { publicKey?: string; secretKey?: string; storeEphemeralKeys?: boolean; + storeEphemeralKeysCallback?: (keys: Array<{ + address: string; + rampId: string; + secret: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; + }>, rampId: string) => Promise; }); createQuote: , rampId: string) => Promise; } VortexSdkContext: { From 0f0d26088a6e193874e43a790ded678498d1c0cc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 10:46:39 +0200 Subject: [PATCH 5/8] test(sdk): cover fail-closed ephemeral storage --- .../sdk/test/vortexSdk.lazyNetworks.test.ts | 45 +++++++++++++++++++ .../test/vortexSdk.storeEphemerals.test.ts | 13 ++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/sdk/test/vortexSdk.lazyNetworks.test.ts b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts index 0f29cb094..a0f6bd3c1 100644 --- a/packages/sdk/test/vortexSdk.lazyNetworks.test.ts +++ b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts @@ -262,6 +262,51 @@ describe("lazy chain WebSocket initialization", () => { expect(calls).toContain("POST /v1/ramp/update"); }); + test("registration awaits custom ephemeral storage and fails before signing or update", async () => { + const calls = mockBackend(Networks.Pendulum); + let rejectStorage!: (reason?: unknown) => void; + let storageStarted!: () => void; + const storageStart = new Promise(resolve => { + storageStarted = resolve; + }); + const storageResult = new Promise((_, reject) => { + rejectStorage = reject; + }); + const sdk = new VortexSdk({ + apiBaseUrl: "https://backend.test", + networkInitializationTimeoutMs: 40, + pendulumWsUrl: DEAD_WEBSOCKET_URL, + secretKey: "sk_test_user", + storeEphemeralKeysCallback: async () => { + storageStarted(); + await storageResult; + }, + }); + + const registration = sdk.registerRamp(quote, { destinationAddress: "0xuser" }); + let registrationSettled = false; + void registration.then( + () => { + registrationSettled = true; + }, + () => { + registrationSettled = true; + } + ); + + await withDeadline(storageStart); + await new Promise(resolve => setTimeout(resolve, 80)); + + expect(registrationSettled).toBe(false); + expect(calls).toContain("POST /v1/ramp/register"); + expect(calls).not.toContain("POST /v1/ramp/update"); + + rejectStorage(new Error("vault unavailable")); + + await expect(withDeadline(registration)).rejects.toThrow("vault unavailable"); + expect(calls).not.toContain("POST /v1/ramp/update"); + }); + test("BRL offramp registration also bypasses unavailable chain WebSockets", async () => { const calls = mockBackend(undefined, offrampQuote); const sdk = createSdk(); diff --git a/packages/sdk/test/vortexSdk.storeEphemerals.test.ts b/packages/sdk/test/vortexSdk.storeEphemerals.test.ts index d2985c90d..5183059ea 100644 --- a/packages/sdk/test/vortexSdk.storeEphemerals.test.ts +++ b/packages/sdk/test/vortexSdk.storeEphemerals.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import { existsSync } from "fs"; +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "crypto"; +import { existsSync, rmSync } from "fs"; import { EphemeralAccountType } from "@vortexfi/shared"; import type { StoredEphemeralKey, VortexSdkConfig } from "../src/types"; import { VortexSdk } from "../src/VortexSdk"; -const RAMP_ID = "ramp_store_test"; +const RAMP_ID = `ramp_store_test_${randomUUID()}`; const BUILT_IN_FILE = `ephemerals_${RAMP_ID}.json`; const ephemerals = { @@ -16,6 +17,10 @@ function makeSdk(config: Partial = {}): VortexSdk { return new VortexSdk({ apiBaseUrl: "http://127.0.0.1:1", ...config }); } +afterAll(() => { + rmSync(BUILT_IN_FILE, { force: true }); +}); + describe("VortexSdk.storeEphemerals", () => { test("passes structured items to the callback instead of the built-in storage", async () => { const calls: Array<{ keys: StoredEphemeralKey[]; rampId: string }> = []; @@ -61,7 +66,7 @@ describe("VortexSdk.storeEphemerals", () => { expect(calls[0]).toHaveLength(2); }); - test("propagates a callback rejection so registration fails closed", async () => { + test("propagates a callback rejection", async () => { const sdk = makeSdk({ storeEphemeralKeysCallback: async () => { throw new Error("vault unavailable"); From c3c2b303eba395e8e8bc3250f13757deb0821c42 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 28 Aug 2026 11:32:42 +0200 Subject: [PATCH 6/8] chore(sdk): prepare package release candidates --- bun.lock | 6 +++--- packages/sdk/package.json | 4 ++-- packages/shared/package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index efff76cb1..4665e3645 100644 --- a/bun.lock +++ b/bun.lock @@ -358,9 +358,9 @@ }, "packages/sdk": { "name": "@vortexfi/sdk", - "version": "0.9.0-rc.3", + "version": "0.9.0-rc.4", "dependencies": { - "@vortexfi/shared": "=0.3.0", + "@vortexfi/shared": "=0.4.0-rc.0", }, "devDependencies": { "@types/bun": "^1.3.1", @@ -376,7 +376,7 @@ }, "packages/shared": { "name": "@vortexfi/shared", - "version": "0.3.0", + "version": "0.4.0-rc.0", "dependencies": { "@paraspell/sdk-pjs": "^11.8.5", "@pendulum-chain/api-solang": "catalog:", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 22f32c367..5cbea1f6f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@vortexfi/shared": "=0.3.0" + "@vortexfi/shared": "=0.4.0-rc.0" }, "devDependencies": { "@types/bun": "^1.3.1", @@ -60,5 +60,5 @@ }, "type": "module", "types": "./dist/index.d.ts", - "version": "0.9.0-rc.3" + "version": "0.9.0-rc.4" } diff --git a/packages/shared/package.json b/packages/shared/package.json index 7b8dcfdb0..3c9a057a0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -65,5 +65,5 @@ "typecheck": "tsc --noEmit" }, "types": "./dist/index.d.ts", - "version": "0.3.0" + "version": "0.4.0-rc.0" } From 31f37def5647404d3c4521dbb45e52cd96ab9225 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 28 Aug 2026 11:45:36 +0200 Subject: [PATCH 7/8] chore(sdk): promote packages to stable releases --- bun.lock | 6 +++--- packages/sdk/package.json | 4 ++-- packages/shared/package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 4665e3645..c5a4b32fd 100644 --- a/bun.lock +++ b/bun.lock @@ -358,9 +358,9 @@ }, "packages/sdk": { "name": "@vortexfi/sdk", - "version": "0.9.0-rc.4", + "version": "0.9.0", "dependencies": { - "@vortexfi/shared": "=0.4.0-rc.0", + "@vortexfi/shared": "=0.4.0", }, "devDependencies": { "@types/bun": "^1.3.1", @@ -376,7 +376,7 @@ }, "packages/shared": { "name": "@vortexfi/shared", - "version": "0.4.0-rc.0", + "version": "0.4.0", "dependencies": { "@paraspell/sdk-pjs": "^11.8.5", "@pendulum-chain/api-solang": "catalog:", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5cbea1f6f..c953ec8e7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@vortexfi/shared": "=0.4.0-rc.0" + "@vortexfi/shared": "=0.4.0" }, "devDependencies": { "@types/bun": "^1.3.1", @@ -60,5 +60,5 @@ }, "type": "module", "types": "./dist/index.d.ts", - "version": "0.9.0-rc.4" + "version": "0.9.0" } diff --git a/packages/shared/package.json b/packages/shared/package.json index 3c9a057a0..6072bb66a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -65,5 +65,5 @@ "typecheck": "tsc --noEmit" }, "types": "./dist/index.d.ts", - "version": "0.4.0-rc.0" + "version": "0.4.0" } From d2d39940391ad32d8705b514aa748122972f1a89 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 28 Aug 2026 12:00:13 +0200 Subject: [PATCH 8/8] docs(sdk): document custom storage in API guides --- docs/api/pages/02-quick-start-with-the-sdk.md | 16 ++++++++++++++- docs/api/pages/05-ephemeral-key-custody.md | 20 ++++++++++++++++--- docs/api/pages/11-production-checklist.md | 3 ++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/api/pages/02-quick-start-with-the-sdk.md b/docs/api/pages/02-quick-start-with-the-sdk.md index fad8dc318..59c76a430 100644 --- a/docs/api/pages/02-quick-start-with-the-sdk.md +++ b/docs/api/pages/02-quick-start-with-the-sdk.md @@ -230,7 +230,21 @@ Most updates happen inside the SDK. For BRL buys, `registerRamp` already submits The SDK creates fresh ephemeral accounts per ramp, signs the transactions Vortex returns, submits ramp updates, and can persist a local backup of ephemeral secrets. This removes the most error-prone parts of a custom integration. -The default backup is **unencrypted**: Node.js writes `ephemerals_{rampId}.json` in the current working directory, while browsers write that key to same-origin localStorage. Treat either as sensitive key material. Browser storage is prototype-grade and readable by every script on the origin. Setting `storeEphemeralKeys: false` disables the SDK backup entirely. See [Ephemeral Key Custody](https://api-docs.vortexfinance.co/ephemeral-key-custody). +The default backup is **unencrypted**: Node.js writes `ephemerals_{rampId}.json` in the current working directory, while browsers write that key to same-origin localStorage. Treat either as sensitive key material. Browser storage is prototype-grade and readable by every script on the origin. + +Production integrations can provide `storeEphemeralKeysCallback` to use encrypted or vault-backed storage instead: + +```js +const sdk = new VortexSdk({ + apiBaseUrl: "https://api.vortexfinance.co", + secretKey: process.env.VORTEX_SECRET_KEY, + storeEphemeralKeysCallback: async (keys, rampId) => { + await encryptedVault.store(rampId, keys); + } +}); +``` + +The callback receives an array of `{ address, rampId, secret, type }` entries and the ramp ID. It replaces the built-in backup, so `storeEphemeralKeys` has no effect when the callback is configured. The SDK awaits it during `registerRamp()` and stops before signing ephemeral-owned transactions if it rejects. Setting `storeEphemeralKeys: false` without a callback disables backup entirely and does not expose the keys elsewhere. See [Ephemeral Key Custody](https://api-docs.vortexfinance.co/ephemeral-key-custody). For quote request races, browser token refresh, wallet-network checks, resumable payment screens, and safe polling, see [Custom UI Integration](https://api-docs.vortexfinance.co/custom-ui-integration). diff --git a/docs/api/pages/05-ephemeral-key-custody.md b/docs/api/pages/05-ephemeral-key-custody.md index 186c84ed2..7901389a5 100644 --- a/docs/api/pages/05-ephemeral-key-custody.md +++ b/docs/api/pages/05-ephemeral-key-custody.md @@ -11,11 +11,25 @@ This is a critical integration responsibility: - Secrets must never be sent to Vortex endpoints, support channels, logs, or analytics. In a browser SDK integration they necessarily exist in browser-visible memory and, by default, same-origin localStorage. - If ephemeral secrets are lost, the partner may be unable to complete recovery for that ramp. Vortex has chain-specific cleanup mechanisms that can recover funds in some cases, but partners should not rely on this for normal operation. -The SDK can store local backups using `storeEphemeralKeys`, which defaults to `true`. In Node.js environments, it writes `ephemerals_{rampId}.json` to the process's current working directory. In browsers, it writes the same plaintext JSON under that key in same-origin localStorage. Neither form is encrypted at rest, and the storage location is not configurable in the current release. +The SDK's built-in backup is controlled by `storeEphemeralKeys`, which defaults to `true`. In Node.js environments, it writes `ephemerals_{rampId}.json` to the process's current working directory. In browsers, it writes the same plaintext JSON under that key in same-origin localStorage. Neither form is encrypted at rest. -When this backup is enabled, persistence is fail-closed. The SDK waits for the backup write after the API creates the ramp but before it signs ephemeral-owned transactions or submits the ramp update. If the write fails, `registerRamp()` rejects and does not continue to the update or start steps. The backend registration may remain incomplete until it expires, but the SDK does not report a usable ramp while its recovery keys are unprotected. Storage errors are deliberately propagated rather than logged and ignored. +For encrypted, vault-backed, or otherwise application-managed persistence, configure `storeEphemeralKeysCallback`: -Treat those backups as sensitive key material. Restrict Node filesystem permissions, exclude files from source control, and define a retention policy that matches operational recovery needs. Browser localStorage is prototype-grade: every same-origin script can read it, and the SDK does not prune terminal entries automatically. Setting `storeEphemeralKeys: false` disables the SDK backup; the current SDK does not expose a replacement storage adapter. +```js +const sdk = new VortexSdk({ + apiBaseUrl: "https://api.vortexfinance.co", + secretKey: process.env.VORTEX_SECRET_KEY, + storeEphemeralKeysCallback: async (keys, rampId) => { + await encryptedVault.store(rampId, keys); + } +}); +``` + +The callback receives an array of `StoredEphemeralKey` objects (`{ address, rampId, secret, type }`) and the ramp ID. When configured, it replaces the built-in file or localStorage backup, and `storeEphemeralKeys` has no effect. The callback owns the storage destination, encryption, access controls, and retention policy; the SDK still does not send the secrets to Vortex. + +Persistence is fail-closed for both the built-in backup and the custom callback. The SDK waits for storage after the API creates the ramp but before it signs ephemeral-owned transactions or submits the ramp update. If the write or callback fails, `registerRamp()` rejects and does not continue to the update or start steps. The backend registration may remain incomplete until it expires, but the SDK does not report a usable ramp while its recovery keys are unprotected. Storage errors are deliberately propagated rather than logged and ignored. + +Treat all backups as sensitive key material. For built-in Node.js storage, restrict filesystem permissions and exclude files from source control. Browser localStorage is prototype-grade: every same-origin script can read it, and the SDK does not prune terminal entries automatically. For custom storage, keep the callback available and deterministic throughout registration, and retain the keys until the operational recovery window has passed. Setting `storeEphemeralKeys: false` without a callback disables backup entirely; it does not expose the secrets through another SDK mechanism. Direct API integrations must implement equivalent custody behavior. At minimum, they should create fresh ephemerals per ramp, store encrypted backups, associate backups with the ramp ID, and verify that recovery material exists before allowing the user to continue. diff --git a/docs/api/pages/11-production-checklist.md b/docs/api/pages/11-production-checklist.md index 96740114c..e8f113e1f 100644 --- a/docs/api/pages/11-production-checklist.md +++ b/docs/api/pages/11-production-checklist.md @@ -6,7 +6,8 @@ Before going live, verify the following: - Store secret API keys only in trusted server-side environments. - Never expose `sk_live_*` or `sk_test_*` keys in browser or mobile code. - Store ephemeral account secrets securely until ramps complete and recovery is no longer needed. -- If using the SDK's default `storeEphemeralKeys: true`, run the SDK from a directory with restricted filesystem permissions, encrypt the backup file yourself, or set `storeEphemeralKeys: false` and implement secure storage. +- For application-managed custody, configure `storeEphemeralKeysCallback` to persist the supplied ephemeral keys in encrypted storage. It replaces the built-in backup, and `registerRamp()` waits for it to succeed before signing ephemeral-owned transactions. +- If using the SDK's default `storeEphemeralKeys: true`, run the SDK from a directory with restricted filesystem permissions and protect the plaintext backup. Set `storeEphemeralKeys: false` only when intentionally disabling backup; without a callback, it does not expose the keys through another storage path. - Persist `quoteId`, `rampId`, user/session ID, partner order ID, and webhook IDs. - Handle quote expiry by creating fresh quotes. - Use webhooks for transaction lifecycle events and verify every webhook signature against `GET /v1/public-key` using RSA-PSS with SHA-256.