diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index 901e44551..737d750f0 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -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. @@ -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 @@ -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`. @@ -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 | |------|-----------------|--------------| @@ -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)`. @@ -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. --- @@ -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. @@ -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__<32chars>` on attribution and approved public reads. - `X-API-Key: sk__<32chars>` on sensitive/authenticated endpoints. -- `Authorization: Bearer ` on `/v1/api-credentials`. +- `Authorization: Bearer ` 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. diff --git a/CLAUDE.md b/CLAUDE.md index 016673ec7..7132e6bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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 @@ -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 @@ -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, diff --git a/MAP.md b/MAP.md index 412f4f0eb..9e86ae1e5 100644 --- a/MAP.md +++ b/MAP.md @@ -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. | diff --git a/README.md b/README.md index 96aade43c..9351b6628 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 ``` diff --git a/apps/api/.env.example b/apps/api/.env.example index ca2269dce..9925651c5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/src/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts index c2b17316c..cf1f490d8 100644 --- a/apps/api/src/config/corsConfig.ts +++ b/apps/api/src/config/corsConfig.ts @@ -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 = { @@ -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)[] }; diff --git a/apps/api/src/config/corsOrigins.test.ts b/apps/api/src/config/corsOrigins.test.ts index fe5b6ada6..78b017478 100644 --- a/apps/api/src/config/corsOrigins.test.ts +++ b/apps/api/src/config/corsOrigins.test.ts @@ -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"]); }); }); diff --git a/apps/api/src/config/corsOrigins.ts b/apps/api/src/config/corsOrigins.ts index e5d25e939..3750ce751 100644 --- a/apps/api/src/config/corsOrigins.ts +++ b/apps/api/src/config/corsOrigins.ts @@ -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()) diff --git a/apps/api/src/config/express.cors.test.ts b/apps/api/src/config/express.cors.test.ts index 9fd738498..ac2d4846e 100644 --- a/apps/api/src/config/express.cors.test.ts +++ b/apps/api/src/config/express.cors.test.ts @@ -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"); }); }); diff --git a/apps/demo/.env.example b/apps/demo/.env.example new file mode 100644 index 000000000..effdfcadc --- /dev/null +++ b/apps/demo/.env.example @@ -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 diff --git a/apps/demo/CLAUDE.md b/apps/demo/CLAUDE.md new file mode 100644 index 000000000..c060abb63 --- /dev/null +++ b/apps/demo/CLAUDE.md @@ -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. diff --git a/apps/demo/index.html b/apps/demo/index.html new file mode 100644 index 000000000..49f2d54bf --- /dev/null +++ b/apps/demo/index.html @@ -0,0 +1,14 @@ + + + + + + + + Vortex Demo + + +
+ + + diff --git a/apps/demo/package.json b/apps/demo/package.json new file mode 100644 index 000000000..35b7bd242 --- /dev/null +++ b/apps/demo/package.json @@ -0,0 +1,34 @@ +{ + "dependencies": { + "@polkadot/api": "catalog:", + "@reown/appkit": "^1.8.8", + "@reown/appkit-adapter-wagmi": "^1.8.8", + "@tanstack/react-query": "^5.64.2", + "@vortexfi/sdk": "workspace:*", + "qrcode.react": "^4.2.0", + "react": "19.2.0", + "react-dom": "19.2.0", + "stellar-sdk": "catalog:", + "viem": "catalog:", + "wagmi": "catalog:" + }, + "devDependencies": { + "@types/bun": "^1.3.1", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "catalog:", + "vite": "^6.2.6" + }, + "name": "vortex-demo", + "private": true, + "scripts": { + "build": "vite build", + "dev": "vite dev --host", + "preview": "vite preview", + "test": "bun test src", + "typecheck": "tsc --noEmit" + }, + "type": "module", + "version": "0.1.0" +} diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx new file mode 100644 index 000000000..8405e43c3 --- /dev/null +++ b/apps/demo/src/App.tsx @@ -0,0 +1,37 @@ +import { lazy, Suspense, useState } from "react"; + +import { CanvasShell } from "./components/CanvasShell"; +import { useBscWallet } from "./hooks/useBscWallet"; + +const apiBaseUrl = import.meta.env.VITE_VORTEX_API_URL?.trim() || "https://api-sandbox.vortexfinance.co"; +const VortexModal = lazy(() => import("./features/vortex").then(module => ({ default: module.VortexModal }))); + +export default function App() { + const [modalOpen, setModalOpen] = useState(false); + const { destinationAddress } = useBscWallet(); + + return ( + +
+

BRAZIL ONRAMP DEMO

+

Buy USDC with PIX

+

A browser-only Vortex integration for USDC on BNB Smart Chain.

+ This prototype expects a user whose Brazilian corridor is already approved. + +
+ + {modalOpen && ( + + setModalOpen(false)} + open={modalOpen} + /> + + )} +
+ ); +} diff --git a/apps/demo/src/components/CanvasShell.tsx b/apps/demo/src/components/CanvasShell.tsx new file mode 100644 index 000000000..0245dc897 --- /dev/null +++ b/apps/demo/src/components/CanvasShell.tsx @@ -0,0 +1,27 @@ +import type { PropsWithChildren } from "react"; + +import { WalletButton } from "./WalletButton"; + +export function CanvasShell({ children }: PropsWithChildren) { + return ( +
+
+ + + +
+ + + +
+
+ +
{children}
+
+ ); +} diff --git a/apps/demo/src/components/WalletButton.tsx b/apps/demo/src/components/WalletButton.tsx new file mode 100644 index 000000000..f777be5f9 --- /dev/null +++ b/apps/demo/src/components/WalletButton.tsx @@ -0,0 +1,40 @@ +import { useAppKit } from "@reown/appkit/react"; + +import { useBscWallet } from "../hooks/useBscWallet"; + +function shortenAddress(address: `0x${string}`) { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function WalletButton() { + const { open } = useAppKit(); + const { destinationAddress, isConnected, isOnBsc, isSwitchingNetwork, switchToBsc } = useBscWallet(); + + if (!isConnected) { + return ( + + ); + } + + if (!isOnBsc) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/demo/src/features/vortex/VortexModal.tsx b/apps/demo/src/features/vortex/VortexModal.tsx new file mode 100644 index 000000000..78ff86ea7 --- /dev/null +++ b/apps/demo/src/features/vortex/VortexModal.tsx @@ -0,0 +1,534 @@ +import { + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + type QuoteResponse, + RampDirection, + VortexSdk, + VortexSdkError +} from "@vortexfi/sdk"; +import { QRCodeSVG } from "qrcode.react"; +import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + clearAuthTokens, + clearPendingPayment, + createAccessTokenProvider, + isTerminalRampStatus, + jwtSubject, + loadAuthTokens, + loadRampHistory, + markRampStarted, + type RampSnapshot, + reconcileRampStart, + requestOtp, + storeRampSnapshot, + updateRampSnapshots, + verifyOtp +} from "./browserState"; +import "./vortexModal.css"; + +export interface VortexModalProps { + apiBaseUrl: string; + destinationAddress: string; + onClose: () => void; + open: boolean; +} + +type AuthStage = "email" | "otp" | "ready"; +type Screen = "quote" | "history" | "payment"; + +type PaymentRamp = RampSnapshot & { depositQrCode: string }; + +const POLL_INTERVAL_MS = 8_000; +const MAX_POLL_INTERVAL_MS = 60_000; +const BRL_AMOUNT = /^\d+(?:\.\d{1,2})?$/; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Something went wrong. Please try again."; +} + +function isValidAmount(value: string): boolean { + return BRL_AMOUNT.test(value) && !/^0+(?:\.0+)?$/.test(value); +} + +function formatDisplayAmount(value: string): string { + const amount = Number(value); + return Number.isFinite(amount) ? amount.toFixed(2) : value; +} + +function createBrlQuote(sdk: VortexSdk, amount: string) { + return sdk.createQuote({ + from: EPaymentMethod.PIX, + inputAmount: amount, + inputCurrency: FiatToken.BRL, + network: Networks.BSC, + outputCurrency: EvmToken.USDC, + paymentMethod: EPaymentMethod.PIX, + rampType: RampDirection.BUY, + to: Networks.BSC + }); +} + +function isUnauthorized(error: unknown): boolean { + return error instanceof VortexSdkError && error.status === 401; +} + +function findPendingPayment(items: RampSnapshot[]): PaymentRamp | undefined { + return items.find((item): item is PaymentRamp => item.awaitingPayment === true && Boolean(item.depositQrCode)); +} + +export function VortexModal({ apiBaseUrl, destinationAddress, onClose, open }: VortexModalProps) { + const [amount, setAmount] = useState("100"); + const [authStage, setAuthStage] = useState("email"); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [otp, setOtp] = useState(""); + const [paymentRamp, setPaymentRamp] = useState(null); + const [paymentExpired, setPaymentExpired] = useState(false); + const [quote, setQuote] = useState(null); + const [quoteLoading, setQuoteLoading] = useState(false); + const [screen, setScreen] = useState("quote"); + const [subject, setSubject] = useState(null); + const quoteRequestId = useRef(0); + + const accessTokenProvider = useMemo(() => createAccessTokenProvider(apiBaseUrl, window.localStorage), [apiBaseUrl]); + const sdk = useMemo( + () => + new VortexSdk({ + accessTokenProvider, + apiBaseUrl + }), + [accessTokenProvider, apiBaseUrl] + ); + + useEffect(() => { + if (!open) return; + const tokens = loadAuthTokens(window.localStorage); + const activeSubject = tokens ? jwtSubject(tokens.accessToken) : null; + const storedHistory = activeSubject ? loadRampHistory(window.localStorage, activeSubject) : []; + const pendingPayment = findPendingPayment(storedHistory); + setAuthStage(tokens ? "ready" : "email"); + setError(null); + setHistory(storedHistory); + setMenuOpen(false); + setPaymentRamp(pendingPayment ?? null); + setScreen(pendingPayment ? "payment" : "quote"); + setSubject(activeSubject); + }, [open]); + + useEffect(() => { + const requestId = ++quoteRequestId.current; + if (!open || screen !== "quote") return; + if (!isValidAmount(amount)) { + setQuote(null); + setQuoteLoading(false); + return; + } + + setQuoteLoading(true); + setError(null); + const timer = window.setTimeout(() => { + void createBrlQuote(sdk, amount) + .then(nextQuote => { + if (requestId === quoteRequestId.current) setQuote(nextQuote); + }) + .catch(fetchError => { + if (requestId === quoteRequestId.current) { + setQuote(null); + setError(errorMessage(fetchError)); + } + }) + .finally(() => { + if (requestId === quoteRequestId.current) setQuoteLoading(false); + }); + }, 350); + + return () => window.clearTimeout(timer); + }, [amount, open, screen, sdk]); + + const activeRampIds = history + .filter(item => !isTerminalRampStatus(item.status)) + .map(item => item.id) + .join(","); + useEffect(() => { + if (!open || !subject || !activeRampIds) return; + const ids = activeRampIds.split(","); + const pollSubject = subject; + let active = true; + let pollTimer: number | undefined; + let pollDelay = POLL_INTERVAL_MS; + + const poll = async () => { + const results = await Promise.allSettled(ids.map(id => sdk.getRampStatus(id))); + if (!active) return; + const updates = results.flatMap((result, index) => + result.status === "fulfilled" + ? [{ currentPhase: String(result.value.currentPhase), id: ids[index], status: String(result.value.status) }] + : [] + ); + pollDelay = updates.length ? POLL_INTERVAL_MS : Math.min(pollDelay * 2, MAX_POLL_INTERVAL_MS); + if (updates.length) { + const nextHistory = updateRampSnapshots(window.localStorage, pollSubject, updates); + setHistory(nextHistory); + setPaymentRamp(current => { + if (!current || nextHistory.find(item => item.id === current.id)?.awaitingPayment !== false) return current; + setScreen(activeScreen => (activeScreen === "payment" ? "quote" : activeScreen)); + return null; + }); + } + pollTimer = window.setTimeout(() => void poll(), pollDelay); + }; + + void poll(); + return () => { + active = false; + if (pollTimer) window.clearTimeout(pollTimer); + }; + }, [activeRampIds, open, sdk, subject]); + + useEffect(() => { + if (!paymentRamp) { + setPaymentExpired(false); + return; + } + const remaining = Date.parse(paymentRamp.expiresAt) - Date.now(); + if (remaining <= 0) { + setPaymentExpired(true); + return; + } + setPaymentExpired(false); + const expiryTimer = window.setTimeout(() => setPaymentExpired(true), remaining); + return () => window.clearTimeout(expiryTimer); + }, [paymentRamp]); + + useEffect(() => { + if (!open) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose, open]); + + const handlePrimaryAction = async (event: FormEvent) => { + event.preventDefault(); + if (loading || (authStage === "ready" && !quote)) return; + setError(null); + setLoading(true); + + try { + if (authStage === "email") { + await requestOtp(apiBaseUrl, email.trim()); + setAuthStage("otp"); + } else if (authStage === "otp") { + const tokens = await verifyOtp(apiBaseUrl, email.trim(), otp.trim(), window.localStorage); + const nextSubject = jwtSubject(tokens.accessToken); + const nextHistory = nextSubject ? loadRampHistory(window.localStorage, nextSubject) : []; + const pendingPayment = findPendingPayment(nextHistory); + setSubject(nextSubject); + setHistory(nextHistory); + setAuthStage("ready"); + setPaymentRamp(pendingPayment ?? null); + if (pendingPayment) setScreen("payment"); + } else { + if (!quote) throw new Error("A current quote is required to continue"); + if (!subject) throw new Error("Sign in again before creating the PIX payment"); + if (new Date(quote.expiresAt).getTime() <= Date.now()) { + setQuote(await createBrlQuote(sdk, amount)); + setError("Your quote expired, so it was refreshed. Review the updated amounts and continue."); + return; + } + const { rampProcess } = await sdk.registerRamp(quote, { destinationAddress }); + if (!rampProcess.depositQrCode) throw new Error("PIX payment instructions were not returned"); + const snapshot = { + awaitingPayment: true, + createdAt: rampProcess.createdAt, + currentPhase: String(rampProcess.currentPhase), + depositQrCode: rampProcess.depositQrCode, + expiresAt: new Date(rampProcess.expiresAt ?? quote.expiresAt).toISOString(), + id: rampProcess.id, + inputAmount: rampProcess.inputAmount, + outputAmount: rampProcess.outputAmount, + status: String(rampProcess.status) + }; + setHistory(storeRampSnapshot(window.localStorage, subject, snapshot)); + setPaymentRamp(snapshot); + setScreen("payment"); + } + } catch (actionError) { + if (isUnauthorized(actionError)) { + clearAuthTokens(window.localStorage); + setAuthStage("email"); + setHistory([]); + setPaymentRamp(null); + setSubject(null); + } + setError(errorMessage(actionError)); + } finally { + setLoading(false); + } + }; + + const handlePaymentMade = async () => { + if (!paymentRamp || !subject || loading) return; + if (Date.parse(paymentRamp.expiresAt) <= Date.now()) { + setPaymentExpired(true); + setError("This PIX payment window has expired. Do not pay this code."); + return; + } + setError(null); + setLoading(true); + try { + const started = await sdk.startRamp(paymentRamp.id); + setHistory( + markRampStarted(window.localStorage, subject, paymentRamp.id, String(started.currentPhase), String(started.status)) + ); + setPaymentRamp(null); + setQuote(null); + setScreen("quote"); + } catch (startError) { + if (isUnauthorized(startError)) { + clearAuthTokens(window.localStorage); + setAuthStage("email"); + setHistory([]); + setPaymentRamp(null); + setSubject(null); + setScreen("quote"); + setError("Your session expired. Sign in again to confirm this payment."); + return; + } + const reconciled = await reconcileRampStart(window.localStorage, subject, paymentRamp.id, id => sdk.getRampStatus(id)); + if (reconciled) { + setHistory(reconciled); + setPaymentRamp(null); + setQuote(null); + setScreen("quote"); + return; + } + setError(errorMessage(startError)); + } finally { + setLoading(false); + } + }; + + const handleExpiredPayment = () => { + if (!paymentRamp || !subject) return; + setHistory(clearPendingPayment(window.localStorage, subject, paymentRamp.id)); + setPaymentRamp(null); + setQuote(null); + setScreen("quote"); + setError(null); + }; + + const handleSignOut = () => { + clearAuthTokens(window.localStorage); + setAuthStage("email"); + setOtp(""); + setHistory([]); + setPaymentRamp(null); + setSubject(null); + setScreen("quote"); + setMenuOpen(false); + setError(null); + }; + + const handleCopy = async () => { + if (!paymentRamp) return; + try { + await navigator.clipboard.writeText(paymentRamp.depositQrCode); + } catch { + setError("Copy failed. Select the PIX code and copy it manually."); + } + }; + + if (!open) return null; + + const primaryLabel = authStage === "email" ? "Sign-up" : authStage === "otp" ? "Verify code" : "Continue"; + const primaryDisabled = + loading || + (authStage === "ready" && (quoteLoading || !quote)) || + (authStage === "email" && !email.trim().includes("@")) || + (authStage === "otp" && !otp.trim()) || + (authStage === "ready" && !destinationAddress); + + return ( +
+
+
+
+ VORTEX / BSC +

{screen === "history" ? "Your ramps" : screen === "payment" ? "Pay with PIX" : "Buy USDC"}

+
+
+ +
+ + {menuOpen && ( +
+ + {authStage === "ready" && ( + + )} +
+ )} +
+
+
+ + {screen === "history" ? ( +
+ {history.length === 0 ? ( +

No ramps yet. Your recent PIX purchases will appear here.

+ ) : ( + history.map(item => ( +
+
+ R$ {item.inputAmount} + {formatDisplayAmount(item.outputAmount)} USDC +
+
+ {item.status ?? item.currentPhase} + +
+
+ )) + )} +

Statuses refresh while this modal is open.

+
+ ) : screen === "payment" && paymentRamp ? ( +
+
+ PIX amount + R$ {paymentRamp.inputAmount} +
+ {paymentExpired ? ( +

This PIX payment window has expired. Do not pay this code.

+ ) : ( + <> +
+ +
+ +