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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
project_doc_fallback_filenames = ["CLAUDE.md"]
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions apps/api/src/api/controllers/wallets.controller.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof registerCdpWallet>>) {
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<void> {
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<void> {
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<void> {
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");
}
}
9 changes: 9 additions & 0 deletions apps/api/src/api/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/api/routes/v1/wallets.route.ts
Original file line number Diff line number Diff line change
@@ -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;
83 changes: 83 additions & 0 deletions apps/api/src/api/services/wallets/cdpWallet.service.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading