diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..64cfaf071 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1 @@ +project_doc_fallback_filenames = ["CLAUDE.md"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c7784d0a..c81218fb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,9 @@ jobs: - name: πŸ§ͺ Dashboard tests run: cd apps/dashboard && bun run test + - name: πŸ§ͺ CDP spike helper tests + run: bun run test:cdp-spike + - name: πŸ§ͺ Rebalancer tests (coverage-gated) run: cd apps/rebalancer && bun run test:coverage diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 78b3d24cb..3c10a4b5d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -37,6 +37,11 @@ jobs: working-directory: apps/frontend run: bun run test:e2e + - name: πŸ§ͺ CDP wallet-choice journeys + if: always() + working-directory: apps/frontend + run: bun run test:e2e:cdp + - name: πŸ“€ Upload report on failure if: failure() uses: actions/upload-artifact@v4 @@ -57,6 +62,11 @@ jobs: working-directory: apps/dashboard run: bun run test:e2e + - name: πŸ§ͺ Dashboard CDP wallet-choice journeys + if: always() + working-directory: apps/dashboard + run: bun run test:e2e:cdp + - name: πŸ“€ Upload dashboard report on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/apps/api/.env.example b/apps/api/.env.example index abb307a9f..71dc9aa17 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -21,6 +21,10 @@ SUPABASE_URL=https://your-project-id.supabase.co SUPABASE_ANON_KEY=your-anon-key-here SUPABASE_SERVICE_KEY=your-service-role-key-here +# Optional Coinbase CDP embedded-wallet ownership verification. +CDP_WALLET_REGISTRATION_ENABLED=false +CDP_PROJECT_ID= + # Database DB_HOST=localhost DB_PORT=5432 diff --git a/apps/api/src/api/controllers/wallets.controller.ts b/apps/api/src/api/controllers/wallets.controller.ts new file mode 100644 index 000000000..240afa3db --- /dev/null +++ b/apps/api/src/api/controllers/wallets.controller.ts @@ -0,0 +1,115 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { UniqueConstraintError } from "sequelize"; +import logger from "../../config/logger"; +import { CdpWalletVerificationError } from "../services/wallets/cdpWallet.service"; +import { + listProfileWallets, + registerCdpWallet, + setWalletMode, + type WalletMode, + WalletModeConflictError, + WalletRegistrationConflictError +} from "../services/wallets/profileWallet.service"; + +function sendError(res: Response, status: number, code: string, message: string): void { + res.status(status).json({ error: { code, message, status } }); +} + +function requireUserId(req: Request, res: Response): string | null { + if (!req.userId) { + sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); + return null; + } + return req.userId; +} + +function sendWalletModeConflict(res: Response, error: WalletModeConflictError): void { + sendError(res, httpStatus.CONFLICT, error.kind === "active_ramp" ? "ACTIVE_RAMP" : "WALLET_NOT_REGISTERED", error.message); +} + +function serializeWallet(wallet: Awaited>) { + return { + address: wallet.address, + chainType: wallet.chainType, + createdAt: wallet.createdAt, + id: wallet.id, + lastUsedAt: wallet.lastUsedAt, + provider: wallet.provider, + providerWalletId: wallet.providerWalletId, + status: wallet.status + }; +} + +export async function getWallets(req: Request, res: Response): Promise { + const profileId = requireUserId(req, res); + if (!profileId) return; + + try { + const result = await listProfileWallets(profileId); + res.status(httpStatus.OK).json({ + mode: result.mode, + wallets: result.wallets.map(serializeWallet) + }); + } catch (error) { + logger.error("Failed to list profile wallets", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to list wallets"); + } +} + +export async function updateWalletMode(req: Request, res: Response): Promise { + const profileId = requireUserId(req, res); + if (!profileId) return; + + const { mode } = (req.body ?? {}) as { mode?: unknown }; + if (mode !== null && mode !== "external" && mode !== "cdp_embedded") { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_WALLET_MODE", "mode must be external, cdp_embedded, or null"); + return; + } + + try { + const updatedMode = await setWalletMode(profileId, mode as WalletMode); + res.status(httpStatus.OK).json({ mode: updatedMode }); + } catch (error) { + if (error instanceof WalletModeConflictError) { + sendWalletModeConflict(res, error); + return; + } + logger.error("Failed to update wallet mode", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to update wallet mode"); + } +} + +export async function createCdpWallet(req: Request, res: Response): Promise { + const profileId = requireUserId(req, res); + if (!profileId) return; + + const accessToken = req.headers.authorization?.slice("Bearer ".length); + const { address, cdpUserId } = (req.body ?? {}) as { address?: unknown; cdpUserId?: unknown }; + if (!accessToken || typeof address !== "string" || typeof cdpUserId !== "string") { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_WALLET", "address and cdpUserId are required"); + return; + } + + try { + const wallet = await registerCdpWallet(profileId, { accessToken, address, cdpUserId }); + res.status(httpStatus.OK).json({ mode: "cdp_embedded", wallet: serializeWallet(wallet) }); + } catch (error) { + if (error instanceof WalletModeConflictError) { + sendWalletModeConflict(res, error); + return; + } + if (error instanceof WalletRegistrationConflictError || error instanceof UniqueConstraintError) { + sendError(res, httpStatus.CONFLICT, "WALLET_CONFLICT", error.message); + return; + } + if (error instanceof CdpWalletVerificationError) { + const status = + error.kind === "disabled" || error.kind === "unavailable" ? httpStatus.SERVICE_UNAVAILABLE : httpStatus.FORBIDDEN; + sendError(res, status, "CDP_WALLET_NOT_VERIFIED", error.message); + return; + } + logger.error("Failed to register CDP wallet", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to register embedded wallet"); + } +} diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index a012174ac..964a54d48 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -34,6 +34,7 @@ import recipientsRoutes from "./recipients.route"; import sessionRoutes from "./session.route"; import siweRoutes from "./siwe.route"; import storageRoutes from "./storage.route"; +import walletsRoutes from "./wallets.route"; import webhookRoutes from "./webhook.route"; type ChainStatus = { @@ -216,6 +217,14 @@ router.use("/onboarding", onboardingRoutes); /** One-record API credential management for authenticated Supabase users. */ router.use("/api-credentials", apiCredentialsRoutes); +/** + * Optional user wallet preference and verified embedded-wallet metadata. + * GET /v1/wallets + * PATCH /v1/wallets/mode + * POST /v1/wallets/cdp + */ +router.use("/wallets", walletsRoutes); + /** * Admin routes for partner-managed API credentials. The partner is addressed by * its unique name; each credential is bound to one explicit profile subject. diff --git a/apps/api/src/api/routes/v1/wallets.route.ts b/apps/api/src/api/routes/v1/wallets.route.ts new file mode 100644 index 000000000..12cf59fd9 --- /dev/null +++ b/apps/api/src/api/routes/v1/wallets.route.ts @@ -0,0 +1,12 @@ +import { Request, Response, Router } from "express"; +import { createCdpWallet, getWallets, updateWalletMode } from "../../controllers/wallets.controller"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(requireAuth); +router.get("/", getWallets as unknown as (req: Request, res: Response) => void); +router.patch("/mode", updateWalletMode as unknown as (req: Request, res: Response) => void); +router.post("/cdp", createCdpWallet as unknown as (req: Request, res: Response) => void); + +export default router; diff --git a/apps/api/src/api/services/wallets/cdpWallet.service.ts b/apps/api/src/api/services/wallets/cdpWallet.service.ts new file mode 100644 index 000000000..b86fd2dbc --- /dev/null +++ b/apps/api/src/api/services/wallets/cdpWallet.service.ts @@ -0,0 +1,83 @@ +import { getAddress, isAddress } from "viem"; +import { config } from "../../../config/vars"; + +interface CdpAuthenticationMethod { + sub?: string; + type: string; +} + +interface CdpEvmAccount { + address?: string; +} + +interface CdpEndUserResponse { + authenticationMethods: CdpAuthenticationMethod[]; + evmAccountObjects: CdpEvmAccount[]; + userId: string; +} + +export class CdpWalletVerificationError extends Error { + constructor( + message: string, + readonly kind: "disabled" | "not_found" | "ownership_mismatch" | "unavailable" + ) { + super(message); + this.name = "CdpWalletVerificationError"; + } +} + +function isEvmAccount(account: CdpEvmAccount): account is { address: string } { + return typeof account.address === "string" && isAddress(account.address); +} + +export async function verifyCdpWalletOwnership(input: { + accessToken: string; + cdpUserId: string; + profileId: string; + address: string; + signal?: AbortSignal; +}): Promise<{ address: string; cdpUserId: string }> { + if (!config.cdp.walletRegistrationEnabled) { + throw new CdpWalletVerificationError("CDP wallet registration is disabled", "disabled"); + } + + let response: Response; + try { + const userId = encodeURIComponent(input.cdpUserId); + const projectId = encodeURIComponent(config.cdp.projectId); + response = await fetch( + `https://api.cdp.coinbase.com/platform/v2/embedded-wallet-api/end-users/${userId}?projectID=${projectId}`, + { + headers: { + Authorization: `Bearer ${input.accessToken}` + }, + signal: input.signal ?? AbortSignal.timeout(10000) + } + ); + } catch (error) { + throw new CdpWalletVerificationError( + `CDP ownership verification failed: ${error instanceof Error ? error.message : String(error)}`, + "unavailable" + ); + } + + if (response.status === 404) { + throw new CdpWalletVerificationError("CDP user was not found", "not_found"); + } + if (!response.ok) { + throw new CdpWalletVerificationError(`CDP ownership verification returned ${response.status}`, "unavailable"); + } + + const user = (await response.json()) as CdpEndUserResponse; + const requestedAddress = getAddress(input.address); + const jwtIdentity = user.authenticationMethods.find(method => method.type === "jwt"); + const ownsAddress = user.evmAccountObjects + .filter(isEvmAccount) + .some(account => getAddress(account.address) === requestedAddress); + + if (user.userId !== input.cdpUserId || jwtIdentity?.sub !== input.profileId || !ownsAddress) { + throw new CdpWalletVerificationError("The CDP wallet does not belong to this Vortex profile", "ownership_mismatch"); + } + + return { address: requestedAddress, cdpUserId: user.userId }; +} diff --git a/apps/api/src/api/services/wallets/profileWallet.service.ts b/apps/api/src/api/services/wallets/profileWallet.service.ts new file mode 100644 index 000000000..6ed25a661 --- /dev/null +++ b/apps/api/src/api/services/wallets/profileWallet.service.ts @@ -0,0 +1,153 @@ +import { Op, Transaction } from "sequelize"; +import { getAddress, isAddress } from "viem"; +import { sequelize } from "../../../models"; +import ProfileWallet from "../../../models/profileWallet.model"; +import RampState from "../../../models/rampState.model"; +import User from "../../../models/user.model"; +import { verifyCdpWalletOwnership } from "./cdpWallet.service"; + +export type WalletMode = "external" | "cdp_embedded" | null; + +const TERMINAL_RAMP_PHASES = ["complete", "failed", "timedOut"]; + +export class WalletModeConflictError extends Error { + constructor( + message: string, + readonly kind: "active_ramp" | "missing_wallet" + ) { + super(message); + this.name = "WalletModeConflictError"; + } +} +export class WalletRegistrationConflictError extends Error {} + +async function getLockedProfile(profileId: string, transaction: Transaction): Promise { + const profile = await User.findByPk(profileId, { + lock: Transaction.LOCK.UPDATE, + transaction + }); + if (!profile) { + throw new Error("Profile not found"); + } + return profile; +} + +async function assertNoActiveRamp(profileId: string, transaction: Transaction): Promise { + const activeRamp = await RampState.findOne({ + attributes: ["id"], + transaction, + where: { + currentPhase: { [Op.notIn]: TERMINAL_RAMP_PHASES }, + userId: profileId + } + }); + if (activeRamp) { + throw new WalletModeConflictError("Wallet mode cannot change while a ramp is active", "active_ramp"); + } +} + +export async function listProfileWallets(profileId: string): Promise<{ + mode: WalletMode; + wallets: ProfileWallet[]; +}> { + const [profile, wallets] = await Promise.all([ + User.findByPk(profileId, { attributes: ["walletMode"] }), + ProfileWallet.findAll({ + order: [["createdAt", "ASC"]], + where: { profileId, status: "active" } + }) + ]); + return { mode: profile?.walletMode ?? null, wallets }; +} + +export async function setWalletMode(profileId: string, mode: WalletMode): Promise { + return sequelize.transaction(async transaction => { + const profile = await getLockedProfile(profileId, transaction); + await assertNoActiveRamp(profileId, transaction); + + if (mode === "cdp_embedded") { + const embeddedWallet = await ProfileWallet.findOne({ + attributes: ["id"], + transaction, + where: { + chainType: "ethereum", + profileId, + provider: "cdp", + status: "active" + } + }); + if (!embeddedWallet) { + throw new WalletModeConflictError("An active verified CDP wallet is required for embedded mode", "missing_wallet"); + } + } + + await profile.update({ walletMode: mode }, { transaction }); + return profile.walletMode; + }); +} + +export async function registerCdpWallet( + profileId: string, + input: { accessToken: string; cdpUserId: string; address: string } +): Promise { + if (!input.cdpUserId.trim() || !isAddress(input.address)) { + throw new WalletRegistrationConflictError("A valid CDP user ID and EVM address are required"); + } + + const verified = await verifyCdpWalletOwnership({ + accessToken: input.accessToken, + address: input.address, + cdpUserId: input.cdpUserId, + profileId + }); + + return sequelize.transaction(async transaction => { + const profile = await getLockedProfile(profileId, transaction); + await assertNoActiveRamp(profileId, transaction); + + const conflictingWallet = await ProfileWallet.findOne({ + transaction, + where: { + [Op.or]: [ + { provider: "cdp", providerWalletId: input.cdpUserId }, + { address: getAddress(verified.address), chainType: "ethereum" } + ] + } + }); + if (conflictingWallet && conflictingWallet.profileId !== profileId) { + throw new WalletRegistrationConflictError("This embedded wallet is already registered to another profile"); + } + + const existingForProfile = await ProfileWallet.findOne({ + transaction, + where: { chainType: "ethereum", profileId, provider: "cdp", status: "active" } + }); + let wallet: ProfileWallet; + if (existingForProfile) { + if ( + existingForProfile.providerWalletId !== input.cdpUserId || + getAddress(existingForProfile.address) !== verified.address + ) { + throw new WalletRegistrationConflictError("This profile already has a different active CDP wallet"); + } + await existingForProfile.update({ lastUsedAt: new Date() }, { transaction }); + wallet = existingForProfile; + } else { + wallet = await ProfileWallet.create( + { + address: verified.address, + chainType: "ethereum", + lastUsedAt: new Date(), + profileId, + provider: "cdp", + providerWalletId: verified.cdpUserId, + status: "active" + }, + { transaction } + ); + } + + await profile.update({ walletMode: "cdp_embedded" }, { transaction }); + return wallet; + }); +} diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2549967ec..ac4468011 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -109,6 +109,26 @@ describe("vars deployment environment validation", () => { expect(result.stderr).toContain("MONERIUM_CLIENT_ID"); }); + it("requires the CDP project ID when wallet registration is enabled", async () => { + const result = await importVarsWithEnv({ + CDP_WALLET_REGISTRATION_ENABLED: "true", + NODE_ENV: "test" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("CDP_PROJECT_ID"); + }); + + it("allows CDP wallet registration when the project ID is present", async () => { + const result = await importVarsWithEnv({ + CDP_PROJECT_ID: "test-cdp-project", + CDP_WALLET_REGISTRATION_ENABLED: "true", + NODE_ENV: "test" + }); + + expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); + }); + it("accepts a lower recipient-invite discount ceiling", async () => { const result = await importVarsWithEnv({ DEPLOYMENT_ENV: "production", @@ -118,7 +138,6 @@ describe("vars deployment environment validation", () => { expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); }); - it("rejects a recipient-invite discount ceiling above the hard cap", async () => { const result = await importVarsWithEnv({ DEPLOYMENT_ENV: "production", diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 49f427e50..998b1374c 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -227,12 +227,20 @@ interface Config { defaults: { vortexEvmPayoutAddress: string | undefined; }; + cdp: { + projectId: string; + walletRegistrationEnabled: boolean; + }; } export const config: Config = { adminSecret: process.env.ADMIN_SECRET || "", amplitudeWss: process.env.AMPLITUDE_WSS || "wss://rpc-amplitude.pendulumchain.tech", backendTestStarterAccount: process.env.BACKEND_TEST_STARTER_ACCOUNT, + cdp: { + projectId: process.env.CDP_PROJECT_ID || "", + walletRegistrationEnabled: process.env.CDP_WALLET_REGISTRATION_ENABLED === "true" + }, database: { database: process.env.DB_NAME || "vortex", dialect: "postgres", @@ -365,6 +373,10 @@ if (config.deploymentEnv === "sandbox" && !config.sandboxEnabled) { throw new Error("DEPLOYMENT_ENV=sandbox requires SANDBOX_ENABLED=true"); } +if (config.cdp.walletRegistrationEnabled && !config.cdp.projectId) { + throw new Error("CDP_PROJECT_ID is required when CDP_WALLET_REGISTRATION_ENABLED=true"); +} + if (config.env === "production") { const missing: string[] = []; diff --git a/apps/api/src/database/migrations/063-add-wallet-mode-to-profiles.ts b/apps/api/src/database/migrations/063-add-wallet-mode-to-profiles.ts new file mode 100644 index 000000000..7ebf8b67a --- /dev/null +++ b/apps/api/src/database/migrations/063-add-wallet-mode-to-profiles.ts @@ -0,0 +1,21 @@ +import { DataTypes, Op, type QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("profiles", "wallet_mode", { + allowNull: true, + type: DataTypes.STRING(32) + }); + await queryInterface.addConstraint("profiles", { + fields: ["wallet_mode"], + name: "profiles_wallet_mode_check", + type: "check", + where: { + wallet_mode: { [Op.in]: ["external", "cdp_embedded"] } + } + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeConstraint("profiles", "profiles_wallet_mode_check"); + await queryInterface.removeColumn("profiles", "wallet_mode"); +} diff --git a/apps/api/src/database/migrations/064-create-profile-wallets.ts b/apps/api/src/database/migrations/064-create-profile-wallets.ts new file mode 100644 index 000000000..93cfed4cc --- /dev/null +++ b/apps/api/src/database/migrations/064-create-profile-wallets.ts @@ -0,0 +1,102 @@ +import { DataTypes, Op, type QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("profile_wallets", { + address: { + allowNull: false, + type: DataTypes.STRING(42) + }, + chain_type: { + allowNull: false, + defaultValue: "ethereum", + type: DataTypes.STRING(32) + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + last_used_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + profile_id: { + allowNull: false, + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }, + provider: { + allowNull: false, + defaultValue: "cdp", + type: DataTypes.STRING(32) + }, + provider_wallet_id: { + allowNull: false, + type: DataTypes.STRING(255) + }, + status: { + allowNull: false, + defaultValue: "active", + type: DataTypes.STRING(32) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("profile_wallets", { + fields: ["provider"], + name: "profile_wallets_provider_check", + type: "check", + where: { provider: { [Op.in]: ["cdp"] } } + }); + await queryInterface.addConstraint("profile_wallets", { + fields: ["chain_type"], + name: "profile_wallets_chain_type_check", + type: "check", + where: { chain_type: { [Op.in]: ["ethereum"] } } + }); + await queryInterface.addConstraint("profile_wallets", { + fields: ["status"], + name: "profile_wallets_status_check", + type: "check", + where: { status: { [Op.in]: ["active", "archived"] } } + }); + await queryInterface.addConstraint("profile_wallets", { + fields: ["provider", "provider_wallet_id"], + name: "uniq_profile_wallets_provider_wallet", + type: "unique" + }); + await queryInterface.addIndex("profile_wallets", ["profile_id", "provider", "chain_type"], { + name: "idx_profile_wallets_profile_provider_chain" + }); + await queryInterface.sequelize.query(` + CREATE UNIQUE INDEX uniq_profile_wallets_active_provider_chain + ON profile_wallets (profile_id, provider, chain_type) + WHERE status = 'active'; + `); + await queryInterface.sequelize.query(` + CREATE UNIQUE INDEX uniq_profile_wallets_evm_address + ON profile_wallets (chain_type, LOWER(address)); + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query("DROP INDEX IF EXISTS uniq_profile_wallets_evm_address;"); + await queryInterface.sequelize.query("DROP INDEX IF EXISTS uniq_profile_wallets_active_provider_chain;"); + await queryInterface.dropTable("profile_wallets"); +} diff --git a/apps/api/src/database/migrator.test.ts b/apps/api/src/database/migrator.test.ts index 47da0e602..c02e251d2 100644 --- a/apps/api/src/database/migrator.test.ts +++ b/apps/api/src/database/migrator.test.ts @@ -8,6 +8,8 @@ import { runMigrations } from "./migrator"; // Must stay in sync with MIGRATION_RENAMES in migrator.ts. const RENAMED = [ ["055-create-api-credentials.ts", "057-create-api-credentials.ts"], + ["055-add-wallet-mode-to-profiles.ts", "063-add-wallet-mode-to-profiles.ts"], + ["056-create-profile-wallets.ts", "064-create-profile-wallets.ts"], ["057-create-partner-managed-profiles.ts", "058-create-partner-managed-profiles.ts"], ["058-add-api-credential-id-to-quote-tickets.ts", "059-add-api-credential-id-to-quote-tickets.ts"] ] as const; @@ -37,7 +39,7 @@ describe("migration rename reconciliation", () => { }); it("renames old-name SequelizeMeta entries instead of re-running the migrations", async () => { - // Simulate a database that executed the files under their pre-rename names (staging). + // Simulate a database that executed the files under their pre-rename names. for (const [oldName, newName] of RENAMED) { await sequelize.query(`DELETE FROM "SequelizeMeta" WHERE name = :newName`, { replacements: { newName } }); await sequelize.query(`INSERT INTO "SequelizeMeta" (name) VALUES (:oldName)`, { replacements: { oldName } }); diff --git a/apps/api/src/database/migrator.ts b/apps/api/src/database/migrator.ts index 59f8f9c37..c55c6d4b0 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -169,7 +169,9 @@ const umzug = new Umzug({ // createTable. This cannot be a migration itself: umzug resolves the pending list before // executing any of them. const MIGRATION_RENAMES: Record = { + "055-add-wallet-mode-to-profiles": "063-add-wallet-mode-to-profiles", "055-create-api-credentials": "057-create-api-credentials", + "056-create-profile-wallets": "064-create-profile-wallets", "057-create-partner-managed-profiles": "058-create-partner-managed-profiles", "058-add-api-credential-id-to-quote-tickets": "059-add-api-credential-id-to-quote-tickets" }; diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index ead5fcb46..f023bc656 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -14,6 +14,7 @@ import PartnerManagedProfile from "./partnerManagedProfile.model"; import PartnerPricingConfig from "./partnerPricingConfig.model"; import ProfilePartnerAssignment from "./profilePartnerAssignment.model"; import ProfileRole from "./profileRole.model"; +import ProfileWallet from "./profileWallet.model"; import ProviderCustomer from "./providerCustomer.model"; import QuoteTicket from "./quoteTicket.model"; import RampState from "./rampState.model"; @@ -49,6 +50,8 @@ ProfilePartnerAssignment.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasMany(ProfileRole, { as: "roles", foreignKey: "userId" }); ProfileRole.belongsTo(User, { as: "user", foreignKey: "userId" }); +User.hasMany(ProfileWallet, { as: "wallets", foreignKey: "profileId" }); +ProfileWallet.belongsTo(User, { as: "profile", foreignKey: "profileId" }); User.hasMany(ApiCredential, { as: "apiCredentials", foreignKey: "profileId" }); ApiCredential.belongsTo(User, { as: "profile", foreignKey: "profileId" }); @@ -113,6 +116,7 @@ const models = { PartnerPricingConfig, ProfilePartnerAssignment, ProfileRole, + ProfileWallet, ProviderCustomer, QuoteTicket, RampState, diff --git a/apps/api/src/models/profileWallet.model.ts b/apps/api/src/models/profileWallet.model.ts new file mode 100644 index 000000000..bc1692176 --- /dev/null +++ b/apps/api/src/models/profileWallet.model.ts @@ -0,0 +1,116 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type ProfileWalletProvider = "cdp"; +export type ProfileWalletChainType = "ethereum"; +export type ProfileWalletStatus = "active" | "archived"; + +export interface ProfileWalletAttributes { + id: string; + profileId: string; + provider: ProfileWalletProvider; + providerWalletId: string; + address: string; + chainType: ProfileWalletChainType; + status: ProfileWalletStatus; + lastUsedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +type ProfileWalletCreationAttributes = Optional< + ProfileWalletAttributes, + "id" | "provider" | "chainType" | "status" | "lastUsedAt" | "createdAt" | "updatedAt" +>; + +class ProfileWallet extends Model implements ProfileWalletAttributes { + declare id: string; + declare profileId: string; + declare provider: ProfileWalletProvider; + declare providerWalletId: string; + declare address: string; + declare chainType: ProfileWalletChainType; + declare status: ProfileWalletStatus; + declare lastUsedAt: Date; + declare createdAt: Date; + declare updatedAt: Date; +} + +ProfileWallet.init( + { + address: { + allowNull: false, + type: DataTypes.STRING(42) + }, + chainType: { + allowNull: false, + defaultValue: "ethereum", + field: "chain_type", + type: DataTypes.STRING(32) + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + lastUsedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "last_used_at", + type: DataTypes.DATE + }, + profileId: { + allowNull: false, + field: "profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }, + provider: { + allowNull: false, + defaultValue: "cdp", + type: DataTypes.STRING(32) + }, + providerWalletId: { + allowNull: false, + field: "provider_wallet_id", + type: DataTypes.STRING(255) + }, + status: { + allowNull: false, + defaultValue: "active", + type: DataTypes.STRING(32) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["profile_id", "provider", "chain_type"], + name: "idx_profile_wallets_profile_provider_chain" + } + ], + modelName: "ProfileWallet", + sequelize, + tableName: "profile_wallets", + timestamps: true + } +); + +export default ProfileWallet; diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index eeb0e2e90..cac87d54f 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -5,16 +5,18 @@ export interface UserAttributes { id: string; // UUID from Supabase Auth email: string; activeCustomerEntityId: string | null; + walletMode: "external" | "cdp_embedded" | null; createdAt: Date; updatedAt: Date; } -type UserCreationAttributes = Optional; +type UserCreationAttributes = Optional; class User extends Model implements UserAttributes { declare id: string; declare email: string; declare activeCustomerEntityId: string | null; + declare walletMode: "external" | "cdp_embedded" | null; declare createdAt: Date; declare updatedAt: Date; } @@ -54,6 +56,11 @@ User.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + walletMode: { + allowNull: true, + field: "wallet_mode", + type: DataTypes.STRING(32) } }, { diff --git a/apps/api/src/test-utils/factories.ts b/apps/api/src/test-utils/factories.ts index c032e476f..88404db3c 100644 --- a/apps/api/src/test-utils/factories.ts +++ b/apps/api/src/test-utils/factories.ts @@ -11,6 +11,7 @@ import { RampDirection, type UnsignedTx } from "@vortexfi/shared"; +import type { Transaction } from "sequelize"; import { digestApiKey, generateApiKey, getSecretKeyLookupPrefix } from "../api/middlewares/apiKeyAuth.helpers"; import { hashTaxReference } from "../api/services/avenia/avenia-customer.service"; import { getOrCreateCustomerEntityForProfile } from "../api/services/customer-entity.service"; @@ -233,24 +234,30 @@ const DEFAULT_UNSIGNED_TX: UnsignedTx = { /** * A ramp state in its initial phase, linked to a fresh quote unless quoteId is given. */ -export async function createTestRampState(overrides: Partial = {}): Promise { +export async function createTestRampState( + overrides: Partial = {}, + transaction?: Transaction +): Promise { const quoteId = overrides.quoteId ?? (await createTestQuote()).id; - return RampState.create({ - currentPhase: "initial", - errorLogs: [], - flowVariant: config.flowVariant, - from: EPaymentMethod.SEPA as DestinationType, - paymentMethod: EPaymentMethod.SEPA, - phaseHistory: [], - postCompleteState: { cleanup: { cleanupAt: null, cleanupCompleted: false, errors: null } }, - presignedTxs: null, - processingLock: { locked: false, lockedAt: null }, - state: (overrides.state ?? {}) as StateMetadata, - to: Networks.Base, - type: RampDirection.BUY, - unsignedTxs: [DEFAULT_UNSIGNED_TX], - userId: null, - ...overrides, - quoteId - }); + return RampState.create( + { + currentPhase: "initial", + errorLogs: [], + flowVariant: config.flowVariant, + from: EPaymentMethod.SEPA as DestinationType, + paymentMethod: EPaymentMethod.SEPA, + phaseHistory: [], + postCompleteState: { cleanup: { cleanupAt: null, cleanupCompleted: false, errors: null } }, + presignedTxs: null, + processingLock: { locked: false, lockedAt: null }, + state: (overrides.state ?? {}) as StateMetadata, + to: Networks.Base, + type: RampDirection.BUY, + unsignedTxs: [DEFAULT_UNSIGNED_TX], + userId: null, + ...overrides, + quoteId + }, + { transaction } + ); } diff --git a/apps/api/src/tests/wallets.integration.test.ts b/apps/api/src/tests/wallets.integration.test.ts new file mode 100644 index 000000000..7f6ce7e7b --- /dev/null +++ b/apps/api/src/tests/wallets.integration.test.ts @@ -0,0 +1,253 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { Transaction } from "sequelize"; +import { config } from "../config/vars"; +import { sequelize } from "../models"; +import ProfileWallet from "../models/profileWallet.model"; +import User from "../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestQuote, createTestRampState, createTestUser } from "../test-utils/factories"; +import { type FakeSupabaseAuth, installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +const WALLET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const CDP_USER_ID = "cdp-user-test-1"; + +let api: TestApp; +let fakeAuth: FakeSupabaseAuth; +const guardedFetch = globalThis.fetch; +const originalCdpConfig = { ...config.cdp }; +const cdpRequests: Array<{ authorization: string | null; url: string }> = []; + +function headers(token: string): Record { + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +function installCdpResponse(profileId: string, address = WALLET_ADDRESS, cdpUserId = CDP_USER_ID): void { + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://api.cdp.coinbase.com/platform/v2/embedded-wallet-api/end-users/")) { + cdpRequests.push({ + authorization: new Headers(init?.headers).get("Authorization"), + url + }); + return Response.json({ + authenticationMethods: [{ sub: profileId, type: "jwt" }], + evmAccountObjects: [{ address }], + userId: cdpUserId + }); + } + return guardedFetch(input, init); + }) as typeof globalThis.fetch; +} + +beforeAll(async () => { + await setupTestDatabase(); + fakeAuth = installFakeSupabaseAuth(); + api = await startTestApp(); +}); + +afterAll(async () => { + globalThis.fetch = guardedFetch; + Object.assign(config.cdp, originalCdpConfig); + if (api) await api.close(); + if (fakeAuth) fakeAuth.restore(); +}); + +beforeEach(async () => { + await resetTestDatabase(); + cdpRequests.length = 0; + Object.assign(config.cdp, { + projectId: "test-cdp-project", + walletRegistrationEnabled: true + }); +}); + +describe("wallet API", () => { + it("requires Supabase authentication", async () => { + const response = await api.request("/v1/wallets"); + expect(response.status).toBe(401); + }); + + it("lists only the authenticated profile's wallet metadata", async () => { + const first = await createTestUser({ email: "wallet-first@example.com" }); + const second = await createTestUser({ email: "wallet-second@example.com" }); + await ProfileWallet.create({ + address: WALLET_ADDRESS, + profileId: first.id, + providerWalletId: CDP_USER_ID + }); + await ProfileWallet.create({ + address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + profileId: second.id, + providerWalletId: "cdp-user-test-2" + }); + + const response = await api.request("/v1/wallets", { + headers: headers(testUserToken(first.id, first.email)) + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { mode: null; wallets: Array<{ providerWalletId: string }> }; + expect(body.mode).toBeNull(); + expect(body.wallets.map(wallet => wallet.providerWalletId)).toEqual([CDP_USER_ID]); + }); + + it("rejects invalid modes and mode changes during a nonterminal ramp", async () => { + const user = await createTestUser({ email: "wallet-mode@example.com" }); + const token = testUserToken(user.id, user.email); + const invalid = await api.request("/v1/wallets/mode", { + body: JSON.stringify({ mode: "automatic" }), + headers: headers(token), + method: "PATCH" + }); + expect(invalid.status).toBe(400); + + const unverifiedEmbedded = await api.request("/v1/wallets/mode", { + body: JSON.stringify({ mode: "cdp_embedded" }), + headers: headers(token), + method: "PATCH" + }); + expect(unverifiedEmbedded.status).toBe(409); + expect(((await unverifiedEmbedded.json()) as { error: { code: string } }).error.code).toBe( + "WALLET_NOT_REGISTERED" + ); + + await createTestRampState({ userId: user.id }); + const conflict = await api.request("/v1/wallets/mode", { + body: JSON.stringify({ mode: "external" }), + headers: headers(token), + method: "PATCH" + }); + expect(conflict.status).toBe(409); + expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe("ACTIVE_RAMP"); + }); + + it("rechecks active ramps after waiting for a concurrent ramp registration", async () => { + const user = await createTestUser({ email: "wallet-mode-race@example.com" }); + const quote = await createTestQuote({ userId: user.id }); + const rampTransaction = await sequelize.transaction(); + let transactionFinished = false; + + try { + await User.findByPk(user.id, { + lock: Transaction.LOCK.UPDATE, + transaction: rampTransaction + }); + await createTestRampState({ quoteId: quote.id, userId: user.id }, rampTransaction); + + const modeRequest = api.request("/v1/wallets/mode", { + body: JSON.stringify({ mode: "external" }), + headers: headers(testUserToken(user.id, user.email)), + method: "PATCH" + }); + + await new Promise(resolve => setTimeout(resolve, 100)); + await rampTransaction.commit(); + transactionFinished = true; + + const response = await modeRequest; + expect(response.status).toBe(409); + expect(((await response.json()) as { error: { code: string } }).error.code).toBe("ACTIVE_RAMP"); + await user.reload(); + expect(user.walletMode).toBeNull(); + } finally { + if (!transactionFinished) { + await rampTransaction.rollback(); + } + } + }); + + it("verifies and idempotently registers a CDP wallet", async () => { + const user = await createTestUser({ email: "wallet-register@example.com" }); + const token = testUserToken(user.id, user.email); + installCdpResponse(user.id); + const request = () => + api.request("/v1/wallets/cdp", { + body: JSON.stringify({ address: WALLET_ADDRESS, cdpUserId: CDP_USER_ID }), + headers: headers(token), + method: "POST" + }); + + const first = await request(); + const second = await request(); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(cdpRequests).toEqual([ + { + authorization: `Bearer ${token}`, + url: `https://api.cdp.coinbase.com/platform/v2/embedded-wallet-api/end-users/${CDP_USER_ID}?projectID=test-cdp-project` + }, + { + authorization: `Bearer ${token}`, + url: `https://api.cdp.coinbase.com/platform/v2/embedded-wallet-api/end-users/${CDP_USER_ID}?projectID=test-cdp-project` + } + ]); + expect(await ProfileWallet.count({ where: { profileId: user.id } })).toBe(1); + await user.reload(); + expect(user.walletMode).toBe("cdp_embedded"); + }); + + it("rejects a wallet already registered to another profile", async () => { + const first = await createTestUser({ email: "wallet-owner@example.com" }); + const second = await createTestUser({ email: "wallet-stranger@example.com" }); + const register = (user: typeof first) => { + installCdpResponse(user.id); + return api.request("/v1/wallets/cdp", { + body: JSON.stringify({ address: WALLET_ADDRESS, cdpUserId: CDP_USER_ID }), + headers: headers(testUserToken(user.id, user.email)), + method: "POST" + }); + }; + + expect((await register(first)).status).toBe(200); + const conflict = await register(second); + expect(conflict.status).toBe(409); + expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe("WALLET_CONFLICT"); + }); + + it("rejects mismatched CDP ownership without persisting metadata", async () => { + const user = await createTestUser({ email: "wallet-mismatch@example.com" }); + installCdpResponse(user.id, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"); + + const response = await api.request("/v1/wallets/cdp", { + body: JSON.stringify({ address: WALLET_ADDRESS, cdpUserId: CDP_USER_ID }), + headers: headers(testUserToken(user.id, user.email)), + method: "POST" + }); + + expect(response.status).toBe(403); + expect(((await response.json()) as { error: { code: string } }).error.code).toBe( + "CDP_WALLET_NOT_VERIFIED" + ); + expect(await ProfileWallet.count()).toBe(0); + }); + + it("atomically rolls back registration when a ramp is active", async () => { + const user = await createTestUser({ email: "wallet-active-ramp@example.com" }); + await createTestRampState({ userId: user.id }); + installCdpResponse(user.id); + + const response = await api.request("/v1/wallets/cdp", { + body: JSON.stringify({ address: WALLET_ADDRESS, cdpUserId: CDP_USER_ID }), + headers: headers(testUserToken(user.id, user.email)), + method: "POST" + }); + + expect(response.status).toBe(409); + expect(((await response.json()) as { error: { code: string } }).error.code).toBe("ACTIVE_RAMP"); + expect(await ProfileWallet.count()).toBe(0); + await user.reload(); + expect(user.walletMode).toBeNull(); + }); + + it("fails closed when server-side CDP ownership verification is disabled", async () => { + const user = await createTestUser({ email: "wallet-disabled@example.com" }); + config.cdp.walletRegistrationEnabled = false; + const response = await api.request("/v1/wallets/cdp", { + body: JSON.stringify({ address: WALLET_ADDRESS, cdpUserId: CDP_USER_ID }), + headers: headers(testUserToken(user.id, user.email)), + method: "POST" + }); + expect(response.status).toBe(503); + expect(await ProfileWallet.count()).toBe(0); + }); +}); diff --git a/apps/cdp-spike/.env.example b/apps/cdp-spike/.env.example new file mode 100644 index 000000000..d50903131 --- /dev/null +++ b/apps/cdp-spike/.env.example @@ -0,0 +1,12 @@ +# Public CDP project identifier. The project must use custom authentication. +VITE_CDP_PROJECT_ID= + +# Vortex API that issues and verifies the existing Supabase JWT. +VITE_API_URL=http://localhost:3000 + +# Optional RPC overrides. Public viem defaults are used when omitted. +VITE_BASE_SEPOLIA_RPC_URL= +VITE_BSC_TESTNET_RPC_URL= + +# Optional exact parent origin when testing against a deployed host. +VITE_SPIKE_PARENT_ORIGIN= diff --git a/apps/cdp-spike/README.md b/apps/cdp-spike/README.md new file mode 100644 index 000000000..895fb02f5 --- /dev/null +++ b/apps/cdp-spike/README.md @@ -0,0 +1,31 @@ +# CDP embedded-wallet compatibility spike + +This disposable app tests Coinbase CDP against Vortex's existing wallet invariants without changing either the +dashboard or widget wallet provider. + +It covers: + +- Supabase custom-auth restoration to the same EOA; +- independent server-side `sub` and address ownership verification; +- the current ERC-20 permit, salted permit, TokenRelayer payload, and Permit2 EIP-712 shapes; +- raw EVM signing for chains outside CDP's direct-send list; +- Base Sepolia direct send and BSC testnet raw-sign-and-broadcast paths; +- secure export inside a cross-origin parent iframe; +- six concurrent browser contexts to exercise Temporary Wallet Secret eviction. + +## Run + +1. Copy `.env.example` to `.env.local` and fill in the CDP project ID and Vortex API URL. +2. From the repository root, run `bun install`. +3. Run `bun run --cwd apps/cdp-spike dev`. +4. Open `http://127.0.0.1:5190/?role=host`. + +The host loads the wallet app from `http://localhost:5190`, making it cross-origin without requiring a second server. +Only the wallet origin needs CDP access, so `http://localhost:5190` must be allowlisted in the CDP project; CDP does +not return its CORS header for the equivalent `127.0.0.1` origin. The app uses Vortex's normal email OTP endpoints; +it proxies those requests through the local Vite server so the API's production CORS policy does not need to allow +a development origin. It never asks for or stores a CDP Wallet Secret, and it does not enable delegation or smart +accounts. + +The two broadcast gates are intentionally user-triggered. They send zero-value self-transfers on testnets but still +consume testnet gas. diff --git a/apps/cdp-spike/index.html b/apps/cdp-spike/index.html new file mode 100644 index 000000000..83c969f97 --- /dev/null +++ b/apps/cdp-spike/index.html @@ -0,0 +1,13 @@ + + + + + + + Vortex CDP compatibility spike + + +
+ + + diff --git a/apps/cdp-spike/package.json b/apps/cdp-spike/package.json new file mode 100644 index 000000000..6cc294b29 --- /dev/null +++ b/apps/cdp-spike/package.json @@ -0,0 +1,28 @@ +{ + "dependencies": { + "@coinbase/cdp-core": "0.0.120", + "@coinbase/cdp-hooks": "0.0.120", + "@coinbase/cdp-react": "0.0.120", + "react": "19.2.0", + "react-dom": "19.2.0", + "viem": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "catalog:", + "vite": "^7.3.5" + }, + "name": "vortex-cdp-spike", + "private": true, + "scripts": { + "build": "vite build", + "dev": "vite --host 0.0.0.0 --port 5190", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "type": "module", + "version": "0.0.0" +} diff --git a/apps/cdp-spike/server/verifyOwnership.test.ts b/apps/cdp-spike/server/verifyOwnership.test.ts new file mode 100644 index 000000000..07a365ba6 --- /dev/null +++ b/apps/cdp-spike/server/verifyOwnership.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { verifyCdpOwnership } from "./verifyOwnership"; + +const ADDRESS = "0x1111111111111111111111111111111111111111"; +const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +function fetchSequence(responses: Response[]): typeof fetch { + return (async () => { + const next = responses.shift(); + if (!next) throw new Error("Unexpected fetch"); + return next; + }) as unknown as typeof fetch; +} + +describe("CDP ownership verification", () => { + it("accepts only when Vortex subject, CDP JWT subject, and address agree", async () => { + const evidence = await verifyCdpOwnership( + { + accessToken: "supabase-token", + address: ADDRESS, + cdpProjectId: "project-id", + cdpUserId: "cdp-user-1", + vortexApiUrl: "https://api.example" + }, + fetchSequence([ + response({ user_id: "supabase-user-1", valid: true }), + response({ + authenticationMethods: [{ kid: "key-1", sub: "supabase-user-1", type: "jwt" }], + evmAccountObjects: [{ address: ADDRESS }], + userId: "cdp-user-1" + }) + ]) + ); + + expect(evidence).toEqual({ + address: ADDRESS, + cdpUserId: "cdp-user-1", + supabaseSubject: "supabase-user-1" + }); + }); + + it("rejects a CDP user bound to another Supabase subject", async () => { + await expect( + verifyCdpOwnership( + { + accessToken: "supabase-token", + address: ADDRESS, + cdpProjectId: "project-id", + cdpUserId: "cdp-user-2", + vortexApiUrl: "https://api.example" + }, + fetchSequence([ + response({ user_id: "supabase-user-1", valid: true }), + response({ + authenticationMethods: [{ kid: "key-1", sub: "supabase-user-2", type: "jwt" }], + evmAccountObjects: [{ address: ADDRESS }], + userId: "cdp-user-2" + }) + ]) + ) + ).rejects.toThrow("not bound to the authenticated Supabase subject"); + }); + + it("rejects an address not returned for the authenticated CDP user", async () => { + await expect( + verifyCdpOwnership( + { + accessToken: "supabase-token", + address: ADDRESS, + cdpProjectId: "project-id", + cdpUserId: "cdp-user-1", + vortexApiUrl: "https://api.example" + }, + fetchSequence([ + response({ user_id: "supabase-user-1", valid: true }), + response({ + authenticationMethods: [{ kid: "key-1", sub: "supabase-user-1", type: "jwt" }], + evmAccountObjects: [{ address: OTHER_ADDRESS }], + userId: "cdp-user-1" + }) + ]) + ) + ).rejects.toThrow("does not own the requested EVM account"); + }); + + it("fails closed when CDP refuses a cross-user lookup", async () => { + await expect( + verifyCdpOwnership( + { + accessToken: "supabase-token", + address: ADDRESS, + cdpProjectId: "project-id", + cdpUserId: "another-users-id", + vortexApiUrl: "https://api.example" + }, + fetchSequence([response({ user_id: "supabase-user-1", valid: true }), response({}, 403)]) + ) + ).rejects.toThrow("CDP rejected the ownership lookup (403)"); + }); +}); diff --git a/apps/cdp-spike/server/verifyOwnership.ts b/apps/cdp-spike/server/verifyOwnership.ts new file mode 100644 index 000000000..162ce8461 --- /dev/null +++ b/apps/cdp-spike/server/verifyOwnership.ts @@ -0,0 +1,75 @@ +import { getAddress } from "viem"; + +interface CdpAuthenticationMethod { + sub?: string; + type: string; +} + +interface CdpEndUser { + authenticationMethods: CdpAuthenticationMethod[]; + evmAccountObjects: Array<{ address: string }>; + userId: string; +} + +interface VerifyOwnershipInput { + accessToken: string; + address: string; + cdpProjectId: string; + cdpUserId: string; + vortexApiUrl: string; +} + +export interface OwnershipEvidence { + address: string; + cdpUserId: string; + supabaseSubject: string; +} + +export async function verifyCdpOwnership( + input: VerifyOwnershipInput, + fetchImplementation: typeof fetch = fetch +): Promise { + const vortexResponse = await fetchImplementation(`${input.vortexApiUrl.replace(/\/$/, "")}/v1/auth/verify`, { + body: JSON.stringify({ access_token: input.accessToken }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + if (!vortexResponse.ok) { + throw new Error(`Vortex rejected the Supabase token (${vortexResponse.status})`); + } + + const vortexIdentity = (await vortexResponse.json()) as { user_id?: string; valid?: boolean }; + if (!vortexIdentity.valid || !vortexIdentity.user_id) { + throw new Error("Vortex did not return a valid Supabase subject"); + } + + const cdpUrl = new URL( + `/platform/v2/embedded-wallet-api/end-users/${encodeURIComponent(input.cdpUserId)}`, + "https://api.cdp.coinbase.com" + ); + cdpUrl.searchParams.set("projectID", input.cdpProjectId); + const cdpResponse = await fetchImplementation(cdpUrl, { + headers: { Authorization: `Bearer ${input.accessToken}` } + }); + if (!cdpResponse.ok) { + throw new Error(`CDP rejected the ownership lookup (${cdpResponse.status})`); + } + + const cdpUser = (await cdpResponse.json()) as CdpEndUser; + const jwtIdentity = cdpUser.authenticationMethods.find(method => method.type === "jwt"); + if (cdpUser.userId !== input.cdpUserId || jwtIdentity?.sub !== vortexIdentity.user_id) { + throw new Error("CDP user is not bound to the authenticated Supabase subject"); + } + + const requestedAddress = getAddress(input.address); + const ownsAddress = cdpUser.evmAccountObjects.some(account => getAddress(account.address) === requestedAddress); + if (!ownsAddress) { + throw new Error("CDP user does not own the requested EVM account"); + } + + return { + address: requestedAddress, + cdpUserId: cdpUser.userId, + supabaseSubject: vortexIdentity.user_id + }; +} diff --git a/apps/cdp-spike/src/HostPage.tsx b/apps/cdp-spike/src/HostPage.tsx new file mode 100644 index 000000000..d687c281b --- /dev/null +++ b/apps/cdp-spike/src/HostPage.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +interface ContextStatus { + address?: string; + detail: string; + status: "fail" | "pass" | "pending"; + userId?: string; +} + +interface SpikeMessage { + address?: string; + contextId?: string; + detail?: string; + source?: string; + type?: string; + userId?: string; +} + +function alternativeLocalOrigin(): string { + const url = new URL(window.location.href); + url.hostname = url.hostname === "localhost" ? "127.0.0.1" : "localhost"; + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.origin; +} + +export function HostPage() { + const walletOrigin = useMemo(alternativeLocalOrigin, []); + const [contextCount, setContextCount] = useState(1); + const [contexts, setContexts] = useState>({}); + const frames = useRef>({}); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + if (event.origin !== walletOrigin || event.data.source !== "vortex-cdp-spike" || !event.data.contextId) return; + const contextId = event.data.contextId; + if (event.data.type === "context-ready") { + setContexts(current => ({ + ...current, + [contextId]: { + address: event.data.address, + detail: "Authenticated and EOA restored", + status: "pass", + userId: event.data.userId + } + })); + } + if (event.data.type === "sign-result") { + setContexts(current => ({ + ...current, + [contextId]: { + ...current[contextId], + detail: event.data.detail ?? "No result detail", + status: event.data.detail?.startsWith("PASS") ? "pass" : "fail" + } + })); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [walletOrigin]); + + const runFirstContextAfterEviction = () => { + setContexts(current => ({ + ...current, + "1": { ...current["1"], detail: "Running signature after all contexts authenticated", status: "pending" } + })); + frames.current["1"]?.contentWindow?.postMessage({ source: "vortex-cdp-spike", type: "run-sign-gate" }, walletOrigin); + }; + + const readyContexts = Object.values(contexts).filter(context => context.status === "pass").length; + + return ( +
+
+

Outer partner page Β· {window.location.origin}

+

CDP nested-widget and session stress harness

+

+ The wallet frames below run on {walletOrigin}, so Coinbase export is nested inside a real cross-origin + iframe. +

+
+ + Open dashboard-origin wallet + + + +
+

+ First authenticate and create the EOA in context 1. Its Vortex session is shared with the additional frames on the + wallet origin; they will authenticate with CDP automatically. +

+
+ +
+ {Array.from({ length: contextCount }, (_, index) => { + const contextId = String(index + 1); + const status = contexts[contextId]; + return ( +
+ Context {contextId} + {status?.detail ?? "Waiting"} + {status?.address && {status.address}} +
+ ); + })} +
+ +
+ {Array.from({ length: contextCount }, (_, index) => { + const contextId = String(index + 1); + const src = `${walletOrigin}/?auto=1&context=${contextId}&parentOrigin=${encodeURIComponent(window.location.origin)}`; + return ( +