Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/vortex-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion docs/api/pages/02-quick-start-with-the-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
20 changes: 17 additions & 3 deletions docs/api/pages/05-ephemeral-key-custody.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion docs/api/pages/11-production-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions docs/api/wire-contract.snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>

StoredEphemeralKey: {
address: string;
rampId: string;
secret: string;
type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" };
}

SubaccountNotFoundError: class SubaccountNotFoundError {
constructor();
readonly code?: string;
Expand Down Expand Up @@ -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<void>;
});
createQuote: <T extends {
api?: boolean;
Expand Down Expand Up @@ -8425,6 +8445,12 @@ VortexSdkConfig: {
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<void>;
}

VortexSdkContext: {
Expand Down
8 changes: 4 additions & 4 deletions docs/security-spec/02-signing-keys/ephemeral-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading