Skip to content
Merged
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
33 changes: 24 additions & 9 deletions .agents/skills/vortex-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ A machine-loadable capability catalog for AI coding agents integrating Vortex in
- `pk_live_*` / `pk_test_*` — public value, sent as `X-Public-Key` for attribution and approved low-sensitivity reads. Quote/widget body `apiKey` remains compatibility transport; if both are present they must match.
- `sk_live_*` / `sk_test_*` — secret value, sent only in `X-API-Key`. **Never expose `sk_*` in a browser or mobile app.** It is returned only when the credential is created.
- If both values are configured, they must belong to the same credential or Vortex returns `403 CREDENTIAL_MISMATCH`. A valid secret may be used without a public value.
- **Ramp registration requires an authenticated profile in every corridor.** The secret credential acts only for its bound profile; raw API clients may instead use that profile's Supabase Bearer session. KYC identity (BRL tax ID, Alfredpay customer, Mykobo customer) is derived from the authenticated profile, never from request fields. Shared dummy/ownerless profiles are invalid.
- **Ramp registration requires an authenticated profile in every corridor.** The SDK accepts either a secret credential for its bound profile or an `accessTokenProvider` for that profile's renewable Supabase Bearer session. KYC identity (BRL tax ID, Alfredpay customer, Mykobo customer) is derived from the authenticated profile, never from request fields. Shared dummy/ownerless profiles are invalid.
- Profile-managed credentials use `POST/GET/DELETE /v1/api-credentials` with a Supabase Bearer session. One profile may have at most five active non-expired credentials; revoke by credential ID disables both values atomically with no DELETE body.
- **Decimals**: all amounts are strings. Never parse them through JS `Number` — use `BigInt`, `decimal.js`, or equivalent.
- **Quote TTL**: quotes expire (see `expiresAt`). Re-quote, never reuse stale quotes.
- **Presigned counts**: this is **per ephemeral-signed transaction, not per ramp**. Each transaction an ephemeral key signs must be submitted as 5 presigned variants — 1 primary plus exactly 4 backups with consecutive nonces in `meta.additionalTxs` (`NUMBER_OF_PRESIGNED_TXS = 5`); the API rejects any other backup count. A ramp can contain several ephemeral-signed transactions across its phases. (The SDK builds these for you; only raw-API integrations need to construct them.)
- **Currently implemented corridors** (all live in the SDK): BRL via PIX, EUR via SEPA (Mykobo), USD via ACH, MXN via SPEI, COP via ACH, ARS via CBU. All support both onramp (BUY) and offramp (SELL). EUR and the bank-transfer corridors deliver to EVM networks only (no AssetHub).
- **EUR enum value**: EUR quotes use `FiatToken.EURC` (not `EUR`) as the currency value, with `"sepa"` as the rail identifier.
- **taxId is deprecated for BRL**: the user's tax ID is derived server-side from the user-linked `sk_*` key. Sending a `taxId` that mismatches the derived one is rejected; stop sending it in new integrations.
- **taxId is deprecated for BRL**: the user's tax ID is derived server-side from the authenticated profile. Sending a `taxId` that mismatches the derived one is rejected; stop sending it in new integrations.
- **Deferred offramp funding**: the SDK checks the source wallet balance at `registerRamp` by default. Server integrations that register before funding a temporary wallet may configure `offrampFundingMode: "deferred"`. This skips only the SDK pre-flight; fund the exact `walletAddress` before signing/submitting user transactions, then update and start before the registration window expires. Backend execution-time balance checks remain authoritative.
- **No secret in markdown**: never paste API keys into source files, logs, screenshots, or support tickets.

Expand All @@ -50,7 +50,7 @@ triggers:
The first call in any ramp flow. A quote pins the price, fees, and route for a short window (see `expiresAt`). You must hold a non-expired quote to call `registerRamp`.

## Prerequisites
- Optional public credential for attribution; a matching secret credential is required later for ramp operations.
- Optional public credential for attribution; a secret credential or Supabase access-token provider is required later for ramp operations.
- Known input currency, output currency, amount, and target network.

## SDK recipe
Expand Down Expand Up @@ -124,7 +124,7 @@ triggers:
```

## When to use
The user is in Brazil (or has BRL/PIX access) and wants to buy crypto. KYC must be completed beforehand through the Vortex app or Widget; the user's CPF/CNPJ is resolved server-side from their user-linked `sk_*` key.
The user is in Brazil (or has BRL/PIX access) and wants to buy crypto. KYC must be completed beforehand through the Vortex app or Widget; the user's CPF/CNPJ is resolved server-side from the authenticated profile.

## Prerequisites
- Fresh quote with `rampType: BUY`, `from: "pix"`, `inputCurrency: FiatToken.BRL`.
Expand Down Expand Up @@ -346,7 +346,7 @@ triggers:
```

## When to use
The user wants to ramp USD, MXN, COP, or ARS over their domestic banking rail. Registration resolves KYC and payment ownership from the secret credential's bound profile; raw API clients may instead use that profile's Bearer session. A technical profile without the user's eligible provider account cannot register that user's ramp. EVM networks only (no AssetHub).
The user wants to ramp USD, MXN, COP, or ARS over their domestic banking rail. Registration resolves KYC and payment ownership from the authenticated profile supplied by a secret credential or Supabase Bearer session. A technical profile without the user's eligible provider account cannot register that user's ramp. EVM networks only (no AssetHub).

| Fiat | Rail identifier | Payment rail |
|------|-----------------|--------------|
Expand All @@ -356,7 +356,7 @@ The user wants to ramp USD, MXN, COP, or ARS over their domestic banking rail. R
| `ARS` | `"cbu"` | CBU bank transfer |

## Prerequisites
- The user completed KYC for the corridor's country via the Vortex app or Widget, and the SDK is authenticated with that user's own `sk_*` key.
- The user completed KYC for the corridor's country via the Vortex app or Widget, and the SDK is authenticated with that user's own `sk_*` key or Supabase session.
- Buy: `destinationAddress` (required); `fiatAccountId`, `walletAddress` optional.
- Sell: `fiatAccountId` and `walletAddress` (both required). List saved accounts with `vortex.listAlfredpayFiatAccounts(country)`.

Expand Down Expand Up @@ -405,7 +405,7 @@ The SDK cannot **create** fiat accounts; they are created during onboarding in t
## Common failures
- `MissingAlfredpayOnrampParametersError` / `MissingAlfredpayOfframpParametersError` — `destinationAddress`, `fiatAccountId`, or `walletAddress` missing.
- `AlfredpayOnrampKycRequiredError` — the authenticated user has no approved KYC for the corridor's country.
- `400` "requires an API key linked to a user" on register — the secret credential is not bound to an eligible profile. Create a profile-managed credential after OTP sign-in or provision a managed profile and issue the credential for that explicit subject.
- `400` "requires an API key linked to a user" on register — the supplied API credential or Bearer session is not bound to an eligible profile. Authenticate as the onboarded user or provision a managed profile and issue a credential for that explicit subject.
- `InsufficientBalanceError` — in the default `"prefunded"` mode, the offramp pre-flight found the source wallet balance below the quote's input amount. A deliberate register-then-fund integration may use `offrampFundingMode: "deferred"`; it must fund before submitting user transactions and starting the ramp.

---
Expand Down Expand Up @@ -486,7 +486,7 @@ First-time integration, environment migration, or when an agent needs to decide
|-----|---------------|---------|
| `pk_live_*` / `pk_test_*` | `X-Public-Key`; browser-safe | Quote/widget attribution and sanitized `getRampInfo()`. It cannot read exact limits, ramp details/history/errors, provider accounts, or mutate ramps/webhooks. |
| `sk_live_*` / `sk_test_*` | `X-API-Key`; server-side only | Authenticated operations as the credential's bound profile and optional partner. Never ship it to browser/mobile bundles. |
| Supabase session | `Authorization: Bearer ...` | First-party profile flows and profile-managed credential lifecycle. |
| Supabase session | `Authorization: Bearer ...` | Browser-safe SDK ramp flows, first-party profile flows, and profile-managed credential lifecycle. |

The public and secret values are not independent records. They are two capabilities of one credential and must share an immutable credential ID. Never pair or migrate values by display name.

Expand All @@ -506,11 +506,26 @@ 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 browser integrations, never configure `secretKey`. Resolve the current renewable Supabase token on every request:

```js
const vortex = new VortexSdk({
apiBaseUrl: "https://api.vortexfinance.co",
publicKey: import.meta.env.VITE_VORTEX_PUBLIC_KEY,
accessTokenProvider: async () => {
const { data } = await supabase.auth.getSession();
return data.session?.access_token;
}
});
```

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.

## REST fallback
Use:
- `X-Public-Key: pk_<env>_<32chars>` on attribution and approved public reads.
- `X-API-Key: sk_<env>_<32chars>` on sensitive/authenticated endpoints.
- `Authorization: Bearer <Supabase JWT>` on `/v1/api-credentials`.
- `Authorization: Bearer <Supabase JWT>` on user-authenticated ramp and credential-management endpoints.

Create a profile-managed credential with `POST /v1/api-credentials`, list one resource per credential with `GET /v1/api-credentials`, and atomically revoke both values with `DELETE /v1/api-credentials/:credentialId` (no body). The secret is present only in the create response.

Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ via XCM (Cross-Consensus Messaging).
Full wayfinding is in [`MAP.md`](MAP.md). This is a **Bun monorepo** using workspaces:

- **apps/frontend** — React 19 + Vite web app → [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md)
- **apps/demo** — minimal browser SDK integration → [`apps/demo/CLAUDE.md`](apps/demo/CLAUDE.md)
- **apps/api** — Express backend (PostgreSQL + Sequelize) → [`apps/api/CLAUDE.md`](apps/api/CLAUDE.md)
- **apps/dashboard** — authenticated React dashboard → [`apps/dashboard/CLAUDE.md`](apps/dashboard/CLAUDE.md)
- **apps/rebalancer** — liquidity rebalancing service → [`apps/rebalancer/CLAUDE.md`](apps/rebalancer/CLAUDE.md)
Expand All @@ -36,6 +37,7 @@ bun install # install all dependencies
bun bootstrap:worktree # install in a fresh worktree and build shared
bun dev # frontend + backend + shared concurrently
bun dev:frontend # http://127.0.0.1:5173
bun dev:demo
bun dev:backend # http://localhost:3000
bun dev:dashboard # http://localhost:5174
bun dev:rebalancer
Expand All @@ -52,7 +54,7 @@ bun typecheck # type check

Run `bun bootstrap:worktree` before tests or development in a new worktree. It installs
the frozen lockfile using a writable, worktree-specific temporary directory and cache,
then builds `@vortexfi/shared` so workspace imports resolve. Set
then builds `@vortexfi/shared` and `@vortexfi/sdk` so workspace imports resolve. Set
`VORTEX_WORKTREE_TMPDIR` only when the default temporary location is unsuitable.

### Netlify deployment diagnostics
Expand Down Expand Up @@ -115,7 +117,7 @@ Every commit message follows [Conventional Commits](https://www.conventionalcomm

- **type** — `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, `style`, `chore`, `ci`, or
`revert`.
- **scope** — the workspace touched: `api`, `frontend`, `dashboard`, `rebalancer`,
- **scope** — the workspace touched: `api`, `frontend`, `demo`, `dashboard`, `rebalancer`,
`shared`, `kyc`, or `sdk`. Use `repo` for cross-cutting changes (root config, CI, monorepo
tooling). One workspace dominates a mixed change? Use that. Truly global? `repo`.
- **summary** — imperative mood ("add", not "added"/"adds"), lowercase after the colon,
Expand Down
1 change: 1 addition & 0 deletions MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ workspace; use [`docs/README.md`](docs/README.md) to locate durable project cont
|---|---|
| `apps/api` | Express API, PostgreSQL/Sequelize models and migrations, block-flow ramp engine, provider integrations, webhooks, and workers. |
| `apps/frontend` | React widget and public web surface. XState ramp/KYC flows, wallets, and partner embedding. |
| `apps/demo` | Minimal browser-only React example for a BRL/PIX to BSC USDC onramp through `@vortexfi/sdk`. |
| `apps/dashboard` | React account dashboard. Auth, customer entities, onboarding, recipients, history, and self-ramp flows. |
| `apps/rebalancer` | Standalone service for cross-chain liquidity correction and profitability-aware rebalancing. |

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This is a Bun monorepo.
|---|---|
| [`apps/api`](apps/api/) | Express API, ramp engine, provider integrations, PostgreSQL workers |
| [`apps/frontend`](apps/frontend/) | Public site and embeddable ramp widget |
| [`apps/demo`](apps/demo/) | Minimal browser SDK example for a BRL/PIX onramp |
| [`apps/dashboard`](apps/dashboard/) | Authenticated customer dashboard |
| [`apps/rebalancer`](apps/rebalancer/) | Liquidity rebalancing service |
| [`packages/shared`](packages/shared/) | Shared contracts, token/network configuration, and signing utilities |
Expand All @@ -33,13 +34,14 @@ bun dev
```

In a fresh Git worktree, run `bun bootstrap:worktree` instead of `bun install`; it also
builds the shared workspace required by the apps.
builds the shared and SDK workspaces required by the apps.

The default development command starts the shared package, API, and widget. Run other
surfaces explicitly:

```bash
bun dev:dashboard
bun dev:demo
bun dev:rebalancer
```

Expand Down
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ NODE_ENV=development
DEPLOYMENT_ENV=development
PORT=3000
LOG_LEVEL=info
# Comma-separated fixed origins allowed to use the browser SDK. Wildcards are ignored.
BROWSER_SDK_ORIGINS=

# Environment Configuration
SANDBOX_ENABLED=false
Expand Down
14 changes: 8 additions & 6 deletions apps/api/src/config/corsConfig.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { CorsOptions } from "cors";
import { buildDashboardPreviewOriginRegex, parseDashboardOrigins } from "./corsOrigins";
import { buildDashboardPreviewOriginRegex, parseFixedOrigins } from "./corsOrigins";
import { config } from "./vars";

// DASHBOARD_ORIGINS is an explicit whitelist (wildcards dropped);
// DASHBOARD_PREVIEW_SITE enables Netlify deploy-preview origins outside production.
const dashboardOrigins = parseDashboardOrigins(process.env.DASHBOARD_ORIGINS);
const dashboardOrigins = parseFixedOrigins(process.env.DASHBOARD_ORIGINS);
const browserSdkOrigins = parseFixedOrigins(process.env.BROWSER_SDK_ORIGINS);
const dashboardPreviewOriginRegex = buildDashboardPreviewOriginRegex(process.env.DASHBOARD_PREVIEW_SITE, config.deploymentEnv);

export const corsOptions: CorsOptions = {
Expand All @@ -27,12 +28,13 @@ export const corsOptions: CorsOptions = {
"https://dashboard.vortexfinance.co",
"https://metrics.vortexfinance.co",
...dashboardOrigins,
...browserSdkOrigins,
dashboardPreviewOriginRegex,
config.deploymentEnv !== "production" ? "https://staging--vortexfi.netlify.app" : null,
config.env === "development" ? "http://localhost:5173" : null,
config.env === "development" ? "http://127.0.0.1:5173" : null,
config.env === "development" ? "http://localhost:5174" : null,
config.env === "development" ? "http://127.0.0.1:5174" : null,
config.deploymentEnv !== "production" ? "http://localhost:5173" : null,
config.deploymentEnv !== "production" ? "http://127.0.0.1:5173" : null,
config.deploymentEnv !== "production" ? "http://localhost:5174" : null,
config.deploymentEnv !== "production" ? "http://127.0.0.1:5174" : null,
config.env === "development" ? "http://localhost:6006" : null
].filter(Boolean) as (string | RegExp)[]
};
10 changes: 5 additions & 5 deletions apps/api/src/config/corsOrigins.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { describe, expect, it } from "bun:test";

import { buildDashboardPreviewOriginRegex, parseDashboardOrigins } from "./corsOrigins";
import { buildDashboardPreviewOriginRegex, parseFixedOrigins } from "./corsOrigins";

describe("parseDashboardOrigins", () => {
describe("parseFixedOrigins", () => {
it("splits, trims and drops empty entries", () => {
expect(parseDashboardOrigins(" https://a.example.com , https://b.example.com ,, ")).toEqual([
expect(parseFixedOrigins(" https://a.example.com , https://b.example.com ,, ")).toEqual([
"https://a.example.com",
"https://b.example.com"
]);
});

it("returns an empty list for undefined", () => {
expect(parseDashboardOrigins(undefined)).toEqual([]);
expect(parseFixedOrigins(undefined)).toEqual([]);
});

it("drops entries containing wildcards", () => {
expect(parseDashboardOrigins("https://*.netlify.app,https://ok.example.com")).toEqual(["https://ok.example.com"]);
expect(parseFixedOrigins("https://*.netlify.app,https://ok.example.com")).toEqual(["https://ok.example.com"]);
});
});

Expand Down
7 changes: 3 additions & 4 deletions apps/api/src/config/corsOrigins.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// CORS origin helpers for the dashboard. Kept separate from express.ts so the
// whitelist logic is unit-testable without booting the app.

// Extra fixed origins for non-production dashboard deployments (comma-separated env
// var, e.g. a staging or preview URL). Resolved once at boot — this stays an explicit
// whitelist per the security spec; wildcards are dropped, never honored.
export function parseDashboardOrigins(raw: string | undefined): string[] {
// Extra fixed origins (comma-separated). Resolved once at boot; wildcards are dropped,
// never honored, so every browser integration remains an explicit operator decision.
export function parseFixedOrigins(raw: string | undefined): string[] {
return (raw ?? "")
.split(",")
.map(origin => origin.trim())
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/config/express.cors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ describe("managed-profile CORS preflight", () => {
await finished;

expect(statusCode).toBe(204);
expect(headers.get("access-control-allow-origin")).toBe("http://localhost:5173");
const allowedHeaders = headers.get("access-control-allow-headers")?.toLowerCase().split(",") ?? [];
expect(allowedHeaders).toContain("x-managed-profile-id");
expect(allowedHeaders).toContain("x-api-key");
expect(allowedHeaders).toContain("authorization");
});
});
5 changes: 5 additions & 0 deletions apps/demo/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
VITE_WALLETCONNECT_PROJECT_ID=
# Optional. Defaults to Wagmi's public BSC transport.
VITE_BSC_RPC_URL=
# Optional. Defaults to the Vortex sandbox API.
VITE_VORTEX_API_URL=https://api-sandbox.vortexfinance.co
12 changes: 12 additions & 0 deletions apps/demo/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# apps/demo - Browser SDK example

Minimal React 19 + Vite example of a backend-free BRL/PIX to BSC USDC onramp. It is an
integration reference, not a second production frontend.

- Keep the corridor and UI intentionally fixed and small.
- Use `@vortexfi/sdk` for quote, registration, signing, update, start, and status calls.
- Browser sessions, ramp snapshots, and SDK ephemeral backups are stored in localStorage
for this prototype.
- Use Wagmi/Reown AppKit for the BSC destination wallet.

Run `bun dev:demo`, `bun test:demo`, `bun typecheck`, or `bun build:demo` from the repository root. The SDK must be built before demo-local typechecking or bundling; `bun bootstrap:worktree` handles this in fresh worktrees.
14 changes: 14 additions & 0 deletions apps/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f1ea" />
<meta name="description" content="Vortex BSC demo" />
<title>Vortex Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading