From ab65289264b5e9188f9d3b2ca9600c0e557e5009 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 19 Aug 2026 15:16:19 -0300 Subject: [PATCH 01/17] feat(sdk): add browser token authentication --- biome.json | 12 +++++ packages/sdk/package.json | 11 ++-- packages/sdk/scripts/build.ts | 33 ++++++++++++ packages/sdk/src/VortexSdk.ts | 22 ++++---- packages/sdk/src/services/ApiService.ts | 34 ++++++++----- packages/sdk/src/storage.browser.ts | 10 ++++ packages/sdk/src/storage.ts | 31 ++++------- packages/sdk/src/types.ts | 8 +++ .../sdk/test/apiService.credentials.test.ts | 45 ++++++++++++++++ .../test/package-consumer/browser-smoke.mjs | 51 +++++++++++++++++++ .../sdk/test/package-consumer/node-smoke.mjs | 6 +++ packages/sdk/test/package-consumer/smoke.ts | 1 + .../sdk/test/vortexSdk.lazyNetworks.test.ts | 31 +++++++++++ scripts/bootstrap-worktree.sh | 1 + 14 files changed, 247 insertions(+), 49 deletions(-) create mode 100644 packages/sdk/scripts/build.ts create mode 100644 packages/sdk/src/storage.browser.ts create mode 100644 packages/sdk/test/package-consumer/browser-smoke.mjs create mode 100644 packages/sdk/test/package-consumer/node-smoke.mjs diff --git a/biome.json b/biome.json index d99b3e448..e45e3c4a7 100644 --- a/biome.json +++ b/biome.json @@ -124,6 +124,18 @@ } } }, + "overrides": [ + { + "assist": { + "actions": { + "source": { + "useSortedKeys": "off" + } + } + }, + "includes": ["packages/sdk/package.json"] + } + ], "vcs": { "clientKind": "git", "enabled": true, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e11b5c9b6..26fc5c690 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -13,8 +13,11 @@ }, "exports": { ".": { + "types": "./dist/index.d.ts", + "browser": "./dist/browser/index.js", + "node": "./dist/index.js", "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "default": "./dist/index.js" }, "./package.json": "./package.json" }, @@ -42,14 +45,16 @@ "scripts": { "analyze": "bun build ./src/index.ts --outdir ./dist/analysis --target=browser --format=esm --minify --splitting --metafile=./dist/analysis/meta.json", "analyze:size": "bun run analyze && ls -lah ./dist/analysis/", - "build": "rm -rf ./dist && bun x tsc -p tsconfig.json && bun build ./src/index.ts --outdir ./dist --target=node --external=\"@polkadot/api\" --external=\"stellar-sdk\"", + "build": "rm -rf ./dist && bun x tsc -p tsconfig.json && bun build ./src/index.ts --outdir ./dist --target=node --format=esm --external=\"@polkadot/api\" --external=\"stellar-sdk\" && bun ./scripts/build.ts", "clean": "rm -rf ./dist", "dev": "bun run --watch ./src/index.ts", "format": "prettier --write .", "lint": "eslint . --ext .ts --max-warnings 0", "prepublishOnly": "bun run build", - "test": "bun run lint && bun test && bun run build && bun run test:package-types && node --input-type=module -e \"await import('./dist/index.js')\"", + "test": "bun run lint && bun test && bun run build && bun run test:package-types && bun run test:package-node && bun run test:package-browser", "test:coverage": "bun run lint && bun test --coverage --coverage-reporter=lcov && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.32 0.15", + "test:package-browser": "node --conditions=browser ./test/package-consumer/browser-smoke.mjs", + "test:package-node": "node ./test/package-consumer/node-smoke.mjs", "test:package-types": "bun x --bun tsc -p test/package-consumer/tsconfig.json", "typecheck": "bun x --bun tsc --noEmit" }, diff --git a/packages/sdk/scripts/build.ts b/packages/sdk/scripts/build.ts new file mode 100644 index 000000000..9147cd80a --- /dev/null +++ b/packages/sdk/scripts/build.ts @@ -0,0 +1,33 @@ +import { resolve } from "node:path"; + +const packageRoot = resolve(import.meta.dir, ".."); +const entrypoint = resolve(packageRoot, "src/index.ts"); +const nodeStoragePath = resolve(packageRoot, "src/storage.ts"); + +const result = await Bun.build({ + entrypoints: [entrypoint], + external: ["@polkadot/api", "stellar-sdk"], + format: "esm", + outdir: resolve(packageRoot, "dist/browser"), + plugins: [ + { + name: "browser-storage", + setup(builder) { + builder.onLoad({ filter: /storage\.ts$/ }, async args => { + if (args.path !== nodeStoragePath) return undefined; + return { + contents: await Bun.file(resolve(packageRoot, "src/storage.browser.ts")).text(), + loader: "ts" + }; + }); + } + } + ], + target: "browser", + write: true +}); + +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index 1ed2c9603..98907caa4 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -53,6 +53,7 @@ export class VortexSdk { private apiService: ApiService; private publicKey: string | undefined; private secretKey: string | undefined; + private accessTokenProvider: VortexSdkConfig["accessTokenProvider"]; private networkManager: NetworkManager; private brlHandler: BrlHandler; private alfredpayHandler: AlfredpayHandler; @@ -61,12 +62,17 @@ export class VortexSdk { private offrampFundingMode: NonNullable; constructor(config: VortexSdkConfig) { - this.apiService = new ApiService(config.apiBaseUrl, config.publicKey, config.secretKey); + if (typeof window !== "undefined" && config.secretKey) { + throw new Error("Browser SDK integrations must use accessTokenProvider and must not configure secretKey."); + } + + this.apiService = new ApiService(config.apiBaseUrl, config.publicKey, config.secretKey, config.accessTokenProvider); this.networkManager = new NetworkManager(config); this.storeEphemeralKeys = config.storeEphemeralKeys ?? true; this.offrampFundingMode = config.offrampFundingMode ?? "prefunded"; this.publicKey = config.publicKey; this.secretKey = config.secretKey; + this.accessTokenProvider = config.accessTokenProvider; this.brlHandler = new BrlHandler( this.apiService, @@ -92,7 +98,7 @@ export class VortexSdk { async createQuote(request: T): Promise> { // Quotes are anonymous-eligible for every corridor (rate discovery); the user-linked - // secretKey is only required at registerRamp. + // secret key or access token is only required at registerRamp. const apiRequest = { ...request, api: true, apiKey: this.publicKey }; const baseQuote = await this.apiService.createQuote(apiRequest); return baseQuote as ExtendedQuoteResponse; @@ -129,10 +135,8 @@ export class VortexSdk { rampProcess: RampProcess; unsignedTransactions: UnsignedTx[]; }> { - if (!this.secretKey) { - throw new Error( - "Ramp registration requires a secretKey (sk_*) that resolves to a Vortex user. Use a user-scoped key or a partner key delegated to a user." - ); + if (!this.secretKey && !this.accessTokenProvider) { + throw new Error("Ramp registration requires a secretKey (sk_*) or accessTokenProvider that resolves to a Vortex user."); } let rampProcess: RampProcess; @@ -347,11 +351,7 @@ export class VortexSdk { } const fileName = `ephemerals_${rampId}.json`; - try { - await storeEphemeralKeys(fileName, ephemeralItems); - } catch (error) { - console.error(`Error storing ephemeral key for ${rampId}:`, error); - } + await storeEphemeralKeys(fileName, ephemeralItems); } private async generateEphemerals(): Promise<{ diff --git a/packages/sdk/src/services/ApiService.ts b/packages/sdk/src/services/ApiService.ts index ecac01ea3..18763bcab 100644 --- a/packages/sdk/src/services/ApiService.ts +++ b/packages/sdk/src/services/ApiService.ts @@ -14,16 +14,17 @@ import type { UpdateRampResponse } from "@vortexfi/shared"; import { handleAPIResponse } from "../errors.js"; -import type { BrlKycResponse } from "../types.js"; +import type { AccessTokenProvider, BrlKycResponse } from "../types.js"; export class ApiService { constructor( private readonly apiBaseUrl: string, private readonly publicKey?: string, - private readonly secretKey?: string + private readonly secretKey?: string, + private readonly accessTokenProvider?: AccessTokenProvider ) {} - private buildHeaders(): Record { + private async buildHeaders(): Promise> { const headers: Record = { "Content-Type": "application/json" }; @@ -32,6 +33,11 @@ export class ApiService { } if (this.secretKey) { headers["X-API-Key"] = this.secretKey; + } else if (this.accessTokenProvider) { + const accessToken = await this.accessTokenProvider(); + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } } return headers; } @@ -39,7 +45,7 @@ export class ApiService { async createQuote(request: CreateQuoteRequest): Promise { const response = await fetch(`${this.apiBaseUrl}/v1/quotes`, { body: JSON.stringify(request), - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "POST" }); @@ -48,7 +54,7 @@ export class ApiService { async getQuote(quoteId: string): Promise { const response = await fetch(`${this.apiBaseUrl}/v1/quotes/${quoteId}`, { - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "GET" }); @@ -58,7 +64,7 @@ export class ApiService { async registerRamp(request: RegisterRampRequest): Promise { const response = await fetch(`${this.apiBaseUrl}/v1/ramp/register`, { body: JSON.stringify(request), - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "POST" }); @@ -68,7 +74,7 @@ export class ApiService { async updateRamp(request: UpdateRampRequest): Promise { const response = await fetch(`${this.apiBaseUrl}/v1/ramp/update`, { body: JSON.stringify(request), - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "POST" }); return handleAPIResponse(response, "/v1/ramp/update"); @@ -77,7 +83,7 @@ export class ApiService { async startRamp(request: StartRampRequest): Promise { const response = await fetch(`${this.apiBaseUrl}/v1/ramp/start`, { body: JSON.stringify(request), - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "POST" }); @@ -87,7 +93,7 @@ export class ApiService { async getRampStatus(rampId: string): Promise { const url = new URL(`${this.apiBaseUrl}/v1/ramp/${rampId}`); const response = await fetch(url.toString(), { - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "GET" }); @@ -96,7 +102,7 @@ export class ApiService { async getRampInfo(): Promise { const response = await fetch(`${this.apiBaseUrl}/v1/ramp-info`, { - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "GET" }); @@ -110,7 +116,7 @@ export class ApiService { } const response = await fetch(url.toString(), { - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "GET" }); @@ -125,7 +131,7 @@ export class ApiService { url.searchParams.append("direction", direction); const response = await fetch(url.toString(), { - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "GET" }); @@ -137,7 +143,7 @@ export class ApiService { url.searchParams.append("pixKey", pixKey); const response = await fetch(url.toString(), { - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "GET" }); @@ -149,7 +155,7 @@ export class ApiService { url.searchParams.append("country", country); const response = await fetch(url.toString(), { - headers: this.buildHeaders(), + headers: await this.buildHeaders(), method: "GET" }); diff --git a/packages/sdk/src/storage.browser.ts b/packages/sdk/src/storage.browser.ts new file mode 100644 index 000000000..0188a2f46 --- /dev/null +++ b/packages/sdk/src/storage.browser.ts @@ -0,0 +1,10 @@ +async function storeEphemeralKeys(fileName: string, data: unknown): Promise { + localStorage.setItem(fileName, JSON.stringify(data, null, 2)); +} + +async function retrieveEphemeralKeys(key: string): Promise { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : null; +} + +export { storeEphemeralKeys, retrieveEphemeralKeys }; diff --git a/packages/sdk/src/storage.ts b/packages/sdk/src/storage.ts index 7e609c70f..8f1ab44f4 100644 --- a/packages/sdk/src/storage.ts +++ b/packages/sdk/src/storage.ts @@ -1,30 +1,19 @@ -const isNode = typeof window === "undefined"; - async function storeEphemeralKeys(fileName: string, data: unknown): Promise { const content = JSON.stringify(data, null, 2); - if (isNode) { - const { writeFile } = await import("fs/promises"); - await writeFile(fileName, content, "utf-8"); - } else { - localStorage.setItem(fileName, content); - } + const { writeFile } = await import("fs/promises"); + await writeFile(fileName, content, "utf-8"); } async function retrieveEphemeralKeys(key: string): Promise { - if (isNode) { - try { - const { readFile } = await import("fs/promises"); - const data = await readFile(`${key}.json`, "utf-8"); - return JSON.parse(data); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return null; - } - throw error; + try { + const { readFile } = await import("fs/promises"); + const data = await readFile(`${key}.json`, "utf-8"); + return JSON.parse(data); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return null; } - } else { - const data = localStorage.getItem(key); - return data ? JSON.parse(data) : null; + throw error; } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index b66aa7d99..7d7c95bf0 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -226,6 +226,8 @@ export interface NetworkConfig { export type OfframpFundingMode = "prefunded" | "deferred"; +export type AccessTokenProvider = () => Promise; + export interface VortexSdkConfig { apiBaseUrl: string; /** @@ -237,6 +239,12 @@ export interface VortexSdkConfig { * Secret API key (sk_live_* or sk_test_*). Sent as the `X-API-Key` header. */ secretKey?: string; + /** + * Resolves the current Supabase access token before each request. Intended for + * browser integrations where the session can refresh while the SDK is active. + * Ignored when `secretKey` is configured. + */ + accessTokenProvider?: AccessTokenProvider; pendulumWsUrl?: string; moonbeamWsUrl?: string; hydrationWsUrl?: string; diff --git a/packages/sdk/test/apiService.credentials.test.ts b/packages/sdk/test/apiService.credentials.test.ts index ae3310b55..6277e2e4d 100644 --- a/packages/sdk/test/apiService.credentials.test.ts +++ b/packages/sdk/test/apiService.credentials.test.ts @@ -51,6 +51,51 @@ describe("ApiService credentials", () => { ); }); + test("resolves and sends a configured access token", async () => { + const fetchMock = mock(() => Promise.resolve(Response.json(rampInfo))); + globalThis.fetch = fetchMock as typeof fetch; + + await new ApiService("https://api.example", "pk_test_public", undefined, async () => "access-token").getRampInfo(); + + expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual( + expect.objectContaining({ Authorization: "Bearer access-token", "X-Public-Key": "pk_test_public" }) + ); + expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty("X-API-Key"); + }); + + test("resolves the access token again for each request", async () => { + const fetchMock = mock(() => Promise.resolve(Response.json(rampInfo))); + globalThis.fetch = fetchMock as typeof fetch; + const tokens = ["first-token", "refreshed-token"]; + const accessTokenProvider = mock(async () => tokens.shift()); + const apiService = new ApiService("https://api.example", undefined, undefined, accessTokenProvider); + + await apiService.getRampInfo(); + await apiService.getRampInfo(); + + expect(accessTokenProvider).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual( + expect.objectContaining({ Authorization: "Bearer first-token" }) + ); + expect(fetchMock.mock.calls[1]?.[1]?.headers).toEqual( + expect.objectContaining({ Authorization: "Bearer refreshed-token" }) + ); + }); + + test("prefers a secret key over an access token provider", async () => { + const fetchMock = mock(() => Promise.resolve(Response.json(rampInfo))); + globalThis.fetch = fetchMock as typeof fetch; + const accessTokenProvider = mock(async () => "access-token"); + + await new ApiService("https://api.example", undefined, "sk_test_secret", accessTokenProvider).getRampInfo(); + + expect(accessTokenProvider).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual( + expect.objectContaining({ "X-API-Key": "sk_test_secret" }) + ); + expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty("Authorization"); + }); + test("preserves a credential mismatch error code", async () => { globalThis.fetch = mock(() => Promise.resolve( diff --git a/packages/sdk/test/package-consumer/browser-smoke.mjs b/packages/sdk/test/package-consumer/browser-smoke.mjs new file mode 100644 index 000000000..079d81445 --- /dev/null +++ b/packages/sdk/test/package-consumer/browser-smoke.mjs @@ -0,0 +1,51 @@ +import { readFile } from "node:fs/promises"; + +const stored = new Map(); +globalThis.localStorage = { + getItem: key => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, value) +}; +globalThis.window = { + addEventListener: () => undefined, + dispatchEvent: () => true, + localStorage: globalThis.localStorage, + removeEventListener: () => undefined +}; + +const resolved = import.meta.resolve("@vortexfi/sdk"); +if (!resolved.endsWith("/dist/browser/index.js")) { + throw new Error(`Expected the browser SDK artifact, resolved ${resolved}`); +} +if ((await readFile(new URL(resolved), "utf8")).includes("fs/promises")) { + throw new Error("Browser SDK artifact must not contain Node filesystem imports"); +} + +const { VortexSdk } = await import("@vortexfi/sdk"); +try { + new VortexSdk({ apiBaseUrl: "https://api.example", secretKey: "sk_test_browser_leak" }); + throw new Error("Expected browser secretKey configuration to fail"); +} catch (error) { + if (!String(error).includes("must not configure secretKey")) throw error; +} + +const sdk = new VortexSdk({ apiBaseUrl: "https://api.example" }); +await sdk.storeEphemerals( + { + EVM: { address: "0xephemeral", secret: "test-secret" } + }, + "browser-smoke" +); + +if (!stored.has("ephemerals_browser-smoke.json")) { + throw new Error("Expected browser ephemeral keys to be stored in localStorage"); +} + +globalThis.localStorage.setItem = () => { + throw new Error("storage unavailable"); +}; +try { + await sdk.storeEphemerals({ EVM: { address: "0xephemeral", secret: "test-secret" } }, "storage-failure"); + throw new Error("Expected browser storage failure to propagate"); +} catch (error) { + if (!String(error).includes("storage unavailable")) throw error; +} diff --git a/packages/sdk/test/package-consumer/node-smoke.mjs b/packages/sdk/test/package-consumer/node-smoke.mjs new file mode 100644 index 000000000..acb0234bb --- /dev/null +++ b/packages/sdk/test/package-consumer/node-smoke.mjs @@ -0,0 +1,6 @@ +const resolved = import.meta.resolve("@vortexfi/sdk"); +if (!resolved.endsWith("/dist/index.js")) { + throw new Error(`Expected the Node SDK artifact, resolved ${resolved}`); +} + +await import("@vortexfi/sdk"); diff --git a/packages/sdk/test/package-consumer/smoke.ts b/packages/sdk/test/package-consumer/smoke.ts index a16799204..dedc8eef2 100644 --- a/packages/sdk/test/package-consumer/smoke.ts +++ b/packages/sdk/test/package-consumer/smoke.ts @@ -1,6 +1,7 @@ import { FiatToken, VortexSdk } from "@vortexfi/sdk"; const sdk = new VortexSdk({ + accessTokenProvider: async () => "current-access-token", apiBaseUrl: "https://api-sandbox.vortexfinance.co", offrampFundingMode: "deferred", storeEphemeralKeys: false diff --git a/packages/sdk/test/vortexSdk.lazyNetworks.test.ts b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts index 7d82cec61..0f29cb094 100644 --- a/packages/sdk/test/vortexSdk.lazyNetworks.test.ts +++ b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts @@ -206,6 +206,37 @@ afterEach(() => { }); describe("lazy chain WebSocket initialization", () => { + test("registration requires a secret key or access token provider", async () => { + const sdk = new VortexSdk({ apiBaseUrl: "https://backend.test", storeEphemeralKeys: false }); + + await expect(sdk.registerRamp(quote, { destinationAddress: "0xuser" })).rejects.toThrow( + "Ramp registration requires a secretKey (sk_*) or accessTokenProvider" + ); + }); + + test("registration accepts an access token provider", async () => { + const calls = mockBackend(); + const sdk = new VortexSdk({ + accessTokenProvider: async () => "access-token", + apiBaseUrl: "https://backend.test", + storeEphemeralKeys: false, + }); + + const createdQuote = await sdk.createQuote({ + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base, + }); + const result = await sdk.registerRamp(createdQuote, { destinationAddress: "0xuser" }); + + expect(result.rampProcess.id).toBe("ramp_1"); + expect(calls).toContain("POST /v1/ramp/register"); + }); + test("BRL quote and registration reach the backend when no signing transactions are returned", async () => { const calls = mockBackend(); const sdk = createSdk(); diff --git a/scripts/bootstrap-worktree.sh b/scripts/bootstrap-worktree.sh index c06cf78c7..1110c888f 100755 --- a/scripts/bootstrap-worktree.sh +++ b/scripts/bootstrap-worktree.sh @@ -11,3 +11,4 @@ cd "$vortex_repo_root" TMPDIR="$vortex_tmp_root" bun install --frozen-lockfile --cache-dir="$vortex_cache_dir" TMPDIR="$vortex_tmp_root" bun run build:shared +TMPDIR="$vortex_tmp_root" bun run build:sdk From 3c02645a38a1199c3e4237191552ad18732ed01b Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 19 Aug 2026 15:17:45 -0300 Subject: [PATCH 02/17] feat(demo): add static browser app shell --- apps/demo/index.html | 14 ++ apps/demo/package.json | 34 ++++ apps/demo/src/App.tsx | 17 ++ apps/demo/src/components/CanvasShell.tsx | 22 ++ apps/demo/src/main.tsx | 17 ++ apps/demo/src/styles.css | 246 +++++++++++++++++++++++ apps/demo/tsconfig.json | 18 ++ apps/demo/vite.config.ts | 6 + bun.lock | 35 ++++ package.json | 9 +- 10 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 apps/demo/index.html create mode 100644 apps/demo/package.json create mode 100644 apps/demo/src/App.tsx create mode 100644 apps/demo/src/components/CanvasShell.tsx create mode 100644 apps/demo/src/main.tsx create mode 100644 apps/demo/src/styles.css create mode 100644 apps/demo/tsconfig.json create mode 100644 apps/demo/vite.config.ts 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..356e31975 --- /dev/null +++ b/apps/demo/src/App.tsx @@ -0,0 +1,17 @@ +import { CanvasShell } from "./components/CanvasShell"; + +export default function App() { + 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. + +
+
+ ); +} diff --git a/apps/demo/src/components/CanvasShell.tsx b/apps/demo/src/components/CanvasShell.tsx new file mode 100644 index 000000000..fd4082c5f --- /dev/null +++ b/apps/demo/src/components/CanvasShell.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from "react"; + +export function CanvasShell({ children }: PropsWithChildren) { + return ( +
+
+ + + + + +
+ +
{children}
+
+ ); +} diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx new file mode 100644 index 000000000..24ef2d125 --- /dev/null +++ b/apps/demo/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App"; +import "./styles.css"; + +const root = document.getElementById("app"); + +if (!root) { + throw new Error("Root element not found"); +} + +createRoot(root).render( + + + +); diff --git a/apps/demo/src/styles.css b/apps/demo/src/styles.css new file mode 100644 index 000000000..fae02ce37 --- /dev/null +++ b/apps/demo/src/styles.css @@ -0,0 +1,246 @@ +:root { + color: #16201d; + background: #f4f1ea; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +button, +a { + font: inherit; +} + +button:focus-visible, +a:focus-visible { + outline: 3px solid rgba(20, 63, 56, 0.25); + outline-offset: 3px; +} + +.canvas-shell { + min-height: 100vh; + background: radial-gradient(circle at 10% 0%, rgba(214, 232, 192, 0.55), transparent 28rem), #f4f1ea; +} + +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + width: min(100% - 48px, 1180px); + min-height: 76px; + margin: 0 auto; + border-bottom: 1px solid rgba(22, 32, 29, 0.12); +} + +.brand { + display: inline-flex; + gap: 9px; + align-items: center; + color: inherit; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + text-decoration: none; +} + +.brand__mark { + width: 18px; + height: 18px; + border: 5px solid #143f38; + border-top-color: #a7ca7a; + border-radius: 50%; + transform: rotate(25deg); +} + +.brand__label { + padding: 3px 7px; + border: 1px solid rgba(22, 32, 29, 0.15); + border-radius: 999px; + color: #60706a; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.top-nav__actions, +.network-badge, +.wallet-button { + display: flex; + align-items: center; +} + +.top-nav__actions { + gap: 10px; +} + +.network-badge { + gap: 7px; + color: #60706a; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.network-badge__dot, +.wallet-button__status { + width: 7px; + height: 7px; + border-radius: 50%; + background: #f0b90b; +} + +.wallet-button { + justify-content: center; + min-height: 42px; + padding: 0 17px; + border: 1px solid #143f38; + border-radius: 12px; + color: #fff; + background: #143f38; + cursor: pointer; + font-size: 13px; + font-weight: 700; + transition: + background 160ms ease, + transform 160ms ease; +} + +.wallet-button:hover:not(:disabled) { + background: #1e554b; + transform: translateY(-1px); +} + +.wallet-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.wallet-button--warning { + border-color: #8a6100; + color: #624700; + background: #f8d66d; +} + +.wallet-button--warning:hover:not(:disabled) { + background: #f3c84e; +} + +.wallet-button--connected { + gap: 8px; + border-color: rgba(20, 63, 56, 0.2); + color: #143f38; + background: rgba(255, 255, 255, 0.68); +} + +.wallet-button--connected:hover:not(:disabled) { + background: #fff; +} + +.wallet-button__status { + background: #4d9d70; + box-shadow: 0 0 0 3px rgba(77, 157, 112, 0.14); +} + +.canvas { + width: min(100% - 48px, 1180px); + min-height: calc(100vh - 76px); + margin: 0 auto; + padding: 56px 0; +} + +.demo-launcher { + display: grid; + place-items: center; + max-width: 680px; + min-height: calc(100vh - 188px); + margin: 0 auto; + text-align: center; +} + +.demo-launcher__eyebrow { + margin: 0; + color: #60706a; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.18em; +} + +.demo-launcher h1 { + margin: 10px 0 4px; + font-family: Georgia, serif; + font-size: clamp(42px, 8vw, 76px); + font-weight: 500; + letter-spacing: -0.055em; +} + +.demo-launcher > p:not(.demo-launcher__eyebrow) { + max-width: 430px; + margin: 0; + color: #60706a; + line-height: 1.6; +} + +.demo-launcher small { + max-width: 430px; + margin-top: 10px; + color: #7a8782; + font-size: 12px; +} + +.demo-launcher__button { + min-height: 54px; + margin-top: 30px; + padding: 0 25px; + border: 0; + border-radius: 14px; + background: #143f38; + color: #fff; + cursor: pointer; + font-weight: 750; +} + +.demo-launcher__button:hover:not(:disabled) { + background: #1e554b; +} + +.demo-launcher__button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +@media (max-width: 600px) { + .top-nav, + .canvas { + width: min(100% - 28px, 1180px); + } + + .top-nav { + min-height: 68px; + } + + .network-badge, + .brand__label { + display: none; + } + + .wallet-button { + min-height: 40px; + padding: 0 13px; + } + + .canvas { + min-height: calc(100vh - 68px); + padding: 32px 0; + } +} diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json new file mode 100644 index 000000000..bd72df02e --- /dev/null +++ b/apps/demo/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "types": ["bun", "vite/client"], + "useDefineForClassFields": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts new file mode 100644 index 000000000..8d23f5106 --- /dev/null +++ b/apps/demo/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()] +}); diff --git a/bun.lock b/bun.lock index 6f91066c8..a55399403 100644 --- a/bun.lock +++ b/bun.lock @@ -145,6 +145,31 @@ "vite": "^7.3.5", }, }, + "apps/demo": { + "name": "vortex-demo", + "version": "0.1.0", + "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", + }, + }, "apps/frontend": { "name": "vortex-frontend", "version": "1.0.0", @@ -4567,6 +4592,8 @@ "vortex-dashboard": ["vortex-dashboard@workspace:apps/dashboard"], + "vortex-demo": ["vortex-demo@workspace:apps/demo"], + "vortex-frontend": ["vortex-frontend@workspace:apps/frontend"], "vortex-rebalancer": ["vortex-rebalancer@workspace:apps/rebalancer"], @@ -5545,6 +5572,10 @@ "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "vortex-demo/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "vortex-demo/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + "vortex-frontend/@typescript-eslint/parser": ["@typescript-eslint/parser@5.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA=="], "vortex-frontend/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -6135,6 +6166,10 @@ "vitest/@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "vortex-demo/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "vortex-demo/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "vortex-frontend/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0" } }, "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w=="], "vortex-frontend/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@5.62.0", "", {}, "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ=="], diff --git a/package.json b/package.json index f2fcb44b6..894b39096 100644 --- a/package.json +++ b/package.json @@ -103,9 +103,10 @@ "private": true, "scripts": { "bootstrap:worktree": "bash scripts/bootstrap-worktree.sh", - "build": "bun run build:shared && bun run build:sdk && bun run build:frontend && bun run build:backend && bun run build:dashboard", + "build": "bun run build:shared && bun run build:sdk && bun run build:demo && bun run build:frontend && bun run build:backend && bun run build:dashboard", "build:backend": "bun run --cwd apps/api build", "build:dashboard": "bun run --cwd apps/dashboard build", + "build:demo": "bun run --cwd apps/demo build", "build:frontend": "bun run --cwd apps/frontend build", "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", @@ -114,6 +115,7 @@ "dev:backend": "bun run --cwd apps/api dev", "dev:contracts:relayer": "bun run --cwd contracts/relayer node", "dev:dashboard": "bun run --cwd apps/dashboard dev", + "dev:demo": "bun run --cwd apps/demo dev", "dev:frontend": "bun run --cwd apps/frontend dev", "dev:rebalancer": "bun run --cwd apps/rebalancer dev", "docs:api:check": "bun docs/api/scripts/check-openapi.ts", @@ -125,13 +127,14 @@ "prepare": "husky", "serve:backend": "bun run --cwd apps/api serve", "serve:frontend": "bun run --cwd apps/frontend preview", - "test": "bun run test:shared && bun run test:kyc && bun run test:sdk && bun run test:rebalancer && bun run test:api && bun run test:frontend", + "test": "bun run test:shared && bun run test:kyc && bun run test:sdk && bun run test:rebalancer && bun run test:api && bun run test:demo && bun run test:frontend", "test:api": "cd apps/api && bun test", "test:contracts:relayer": "bun run --cwd contracts/relayer test", "test:coverage": "bun run --cwd packages/shared test:coverage && bun run --cwd packages/sdk test:coverage && bun run --cwd apps/rebalancer test:coverage && bun run --cwd apps/api test:coverage && bun run --cwd apps/frontend test:coverage && bun scripts/coverage-report.ts", "test:coverage:html": "bun scripts/coverage-report.ts --html coverage/index.html && open coverage/index.html", "test:db:start": "bun run --cwd apps/api test:db:start", "test:db:stop": "bun run --cwd apps/api test:db:stop", + "test:demo": "bun run --cwd apps/demo test", "test:e2e": "cd apps/frontend && bun run test:e2e", "test:e2e:dashboard": "cd apps/dashboard && bun run test:e2e", "test:frontend": "cd apps/frontend && bunx vitest run", @@ -139,7 +142,7 @@ "test:rebalancer": "cd apps/rebalancer && bun test", "test:sdk": "bun run --cwd packages/sdk test", "test:shared": "cd packages/shared && bun test", - "typecheck": "bun run --cwd packages/shared typecheck && bun run --cwd packages/sdk typecheck && bun run --cwd packages/kyc typecheck && bun run --cwd apps/api typecheck && bun run --cwd apps/frontend typecheck && bun run --cwd apps/dashboard typecheck && bun run --cwd apps/rebalancer typecheck", + "typecheck": "bun run build:sdk && bun run --cwd packages/shared typecheck && bun run --cwd packages/sdk typecheck && bun run --cwd packages/kyc typecheck && bun run --cwd apps/api typecheck && bun run --cwd apps/demo typecheck && bun run --cwd apps/frontend typecheck && bun run --cwd apps/dashboard typecheck && bun run --cwd apps/rebalancer typecheck", "verify": "biome check --no-errors-on-unmatched", "wire-contract:check": "bun scripts/wire-contract/generate-report.ts --check", "wire-contract:update": "bun scripts/wire-contract/generate-report.ts --update" From ded2a5a834e0548e098d31637f39360dac8b7cbe Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 19 Aug 2026 15:18:20 -0300 Subject: [PATCH 03/17] feat(demo): add BSC wallet connection --- apps/demo/.env.example | 5 +++ apps/demo/src/components/CanvasShell.tsx | 13 ++++-- apps/demo/src/components/WalletButton.tsx | 40 ++++++++++++++++++ apps/demo/src/hooks/useBscWallet.ts | 16 ++++++++ apps/demo/src/index.ts | 4 ++ apps/demo/src/main.tsx | 10 ++++- apps/demo/src/wagmi.ts | 50 +++++++++++++++++++++++ 7 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 apps/demo/.env.example create mode 100644 apps/demo/src/components/WalletButton.tsx create mode 100644 apps/demo/src/hooks/useBscWallet.ts create mode 100644 apps/demo/src/index.ts create mode 100644 apps/demo/src/wagmi.ts 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/src/components/CanvasShell.tsx b/apps/demo/src/components/CanvasShell.tsx index fd4082c5f..0245dc897 100644 --- a/apps/demo/src/components/CanvasShell.tsx +++ b/apps/demo/src/components/CanvasShell.tsx @@ -1,5 +1,7 @@ import type { PropsWithChildren } from "react"; +import { WalletButton } from "./WalletButton"; + export function CanvasShell({ children }: PropsWithChildren) { return (
@@ -10,10 +12,13 @@ export function CanvasShell({ children }: PropsWithChildren) { Demo - - +
+ + + +
{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/hooks/useBscWallet.ts b/apps/demo/src/hooks/useBscWallet.ts new file mode 100644 index 000000000..48495309b --- /dev/null +++ b/apps/demo/src/hooks/useBscWallet.ts @@ -0,0 +1,16 @@ +import { bsc } from "@reown/appkit/networks"; +import { useAccount, useSwitchChain } from "wagmi"; + +export function useBscWallet() { + const { address, chainId, isConnected } = useAccount(); + const { isPending: isSwitchingNetwork, switchChainAsync } = useSwitchChain(); + const isOnBsc = isConnected && chainId === bsc.id; + + return { + destinationAddress: isOnBsc ? address : undefined, + isConnected, + isOnBsc, + isSwitchingNetwork, + switchToBsc: () => switchChainAsync({ chainId: bsc.id }) + }; +} diff --git a/apps/demo/src/index.ts b/apps/demo/src/index.ts new file mode 100644 index 000000000..ca1eb5785 --- /dev/null +++ b/apps/demo/src/index.ts @@ -0,0 +1,4 @@ +export { CanvasShell } from "./components/CanvasShell"; +export { WalletButton } from "./components/WalletButton"; +export { useBscWallet } from "./hooks/useBscWallet"; +export { bscNetwork, wagmiConfig } from "./wagmi"; diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx index 24ef2d125..c70154448 100644 --- a/apps/demo/src/main.tsx +++ b/apps/demo/src/main.tsx @@ -1,9 +1,13 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { WagmiProvider } from "wagmi"; import App from "./App"; import "./styles.css"; +import { wagmiConfig } from "./wagmi"; +const queryClient = new QueryClient(); const root = document.getElementById("app"); if (!root) { @@ -12,6 +16,10 @@ if (!root) { createRoot(root).render( - + + + + + ); diff --git a/apps/demo/src/wagmi.ts b/apps/demo/src/wagmi.ts new file mode 100644 index 000000000..339b6f37c --- /dev/null +++ b/apps/demo/src/wagmi.ts @@ -0,0 +1,50 @@ +import { bsc } from "@reown/appkit/networks"; +import { createAppKit } from "@reown/appkit/react"; +import { WagmiAdapter } from "@reown/appkit-adapter-wagmi"; +import { http } from "wagmi"; + +export const bscNetwork = bsc; + +const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID?.trim(); + +if (!projectId) { + throw new Error("VITE_WALLETCONNECT_PROJECT_ID is required"); +} + +const networks: [typeof bsc] = [bsc]; +const bscRpcUrl = import.meta.env.VITE_BSC_RPC_URL?.trim(); +const wagmiAdapter = new WagmiAdapter({ + networks, + projectId, + ssr: false, + transports: { + [bsc.id]: bscRpcUrl ? http(bscRpcUrl) : http() + } +}); + +createAppKit({ + adapters: [wagmiAdapter], + enableEIP6963: true, + enableWalletGuide: false, + features: { + analytics: false, + email: false, + onramp: false, + socials: false, + swaps: false + }, + metadata: { + description: "BSC wallet demo for Vortex", + icons: [], + name: "Vortex Demo", + url: window.location.origin + }, + networks, + projectId, + themeMode: "light", + themeVariables: { + "--w3m-accent": "#143f38" + } +}); + +export const wagmiConfig = wagmiAdapter.wagmiConfig; From 545a1116d0ed8fdd4fbec2779ff9f04beefba069 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 19 Aug 2026 15:19:09 -0300 Subject: [PATCH 04/17] feat(demo): add browser onramp flow --- apps/api/.env.example | 2 + apps/api/src/config/corsConfig.ts | 14 +- apps/api/src/config/corsOrigins.test.ts | 10 +- apps/api/src/config/corsOrigins.ts | 7 +- apps/api/src/config/express.cors.test.ts | 2 + apps/demo/src/App.tsx | 22 +- apps/demo/src/features/vortex/VortexModal.tsx | 504 ++++++++++++++++++ .../src/features/vortex/browserState.test.ts | 162 ++++++ apps/demo/src/features/vortex/browserState.ts | 232 ++++++++ apps/demo/src/features/vortex/index.ts | 1 + apps/demo/src/features/vortex/vortexModal.css | 375 +++++++++++++ 11 files changed, 1315 insertions(+), 16 deletions(-) create mode 100644 apps/demo/src/features/vortex/VortexModal.tsx create mode 100644 apps/demo/src/features/vortex/browserState.test.ts create mode 100644 apps/demo/src/features/vortex/browserState.ts create mode 100644 apps/demo/src/features/vortex/index.ts create mode 100644 apps/demo/src/features/vortex/vortexModal.css diff --git a/apps/api/.env.example b/apps/api/.env.example index e6f56f676..0ec7c192a 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 85e67c211..b2b1e392d 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 = { @@ -26,12 +27,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/src/App.tsx b/apps/demo/src/App.tsx index 356e31975..8405e43c3 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,6 +1,15 @@ +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 (
@@ -8,10 +17,21 @@ export default function App() {

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/features/vortex/VortexModal.tsx b/apps/demo/src/features/vortex/VortexModal.tsx new file mode 100644 index 000000000..11d444d27 --- /dev/null +++ b/apps/demo/src/features/vortex/VortexModal.tsx @@ -0,0 +1,504 @@ +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, + loadAuthTokens, + loadRampHistory, + markRampStarted, + type RampSnapshot, + 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 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; +} + +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 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 storedHistory = loadRampHistory(window.localStorage); + const pendingPayment = storedHistory.find( + (item): item is PaymentRamp => item.awaitingPayment === true && Boolean(item.depositQrCode) + ); + const authenticated = Boolean(loadAuthTokens(window.localStorage)); + setAuthStage(authenticated ? "ready" : "email"); + setError(null); + setHistory(storedHistory); + setMenuOpen(false); + setPaymentRamp(pendingPayment ?? null); + setScreen(pendingPayment && authenticated ? "payment" : "quote"); + }, [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 historyIds = history.map(item => item.id).join(","); + useEffect(() => { + if (!open || !historyIds) return; + const ids = historyIds.split(","); + let active = true; + let pollTimer: number | undefined; + + 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) }] + : [] + ); + if (updates.length) { + const nextHistory = updateRampSnapshots(window.localStorage, 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(), POLL_INTERVAL_MS); + }; + + void poll(); + return () => { + active = false; + if (pollTimer) window.clearTimeout(pollTimer); + }; + }, [historyIds, open, sdk]); + + 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") { + await verifyOtp(apiBaseUrl, email.trim(), otp.trim(), window.localStorage); + setAuthStage("ready"); + if (paymentRamp) setScreen("payment"); + } else { + if (!quote) throw new Error("A current quote is required to continue"); + const activeQuote = new Date(quote.expiresAt).getTime() > Date.now() ? quote : await createBrlQuote(sdk, amount); + setQuote(activeQuote); + const { rampProcess } = await sdk.registerRamp(activeQuote, { 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 ?? activeQuote.expiresAt).toISOString(), + id: rampProcess.id, + inputAmount: rampProcess.inputAmount, + outputAmount: rampProcess.outputAmount, + status: String(rampProcess.status) + }; + setHistory(storeRampSnapshot(window.localStorage, snapshot)); + setPaymentRamp(snapshot); + setScreen("payment"); + } + } catch (actionError) { + if (isUnauthorized(actionError)) { + clearAuthTokens(window.localStorage); + setAuthStage("email"); + } + setError(errorMessage(actionError)); + } finally { + setLoading(false); + } + }; + + const handlePaymentMade = async () => { + if (!paymentRamp || 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, paymentRamp.id, String(started.currentPhase), String(started.status))); + setPaymentRamp(null); + setQuote(null); + setScreen("quote"); + } catch (startError) { + if (isUnauthorized(startError)) { + clearAuthTokens(window.localStorage); + setAuthStage("email"); + setScreen("quote"); + setError("Your session expired. Sign in again to confirm this payment."); + return; + } + try { + const current = await sdk.getRampStatus(paymentRamp.id); + if (String(current.currentPhase) !== "initial") { + setHistory( + markRampStarted(window.localStorage, paymentRamp.id, String(current.currentPhase), String(current.status)) + ); + setPaymentRamp(null); + setQuote(null); + setScreen("quote"); + return; + } + } catch { + // Keep the original start error; status reconciliation is best effort. + } + setError(errorMessage(startError)); + } finally { + setLoading(false); + } + }; + + const handleExpiredPayment = () => { + if (!paymentRamp) return; + setHistory(clearPendingPayment(window.localStorage, paymentRamp.id)); + setPaymentRamp(null); + setQuote(null); + setScreen("quote"); + setError(null); + }; + + const handleSignOut = () => { + clearAuthTokens(window.localStorage); + setAuthStage("email"); + setOtp(""); + 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.

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