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
30 changes: 3 additions & 27 deletions app/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,10 @@

import { encodedRedirect } from "@/lib/utils/utils";
import { createClient } from "@/lib/utils/supabase/server";
import { createWalletSet, createWallet } from "@/lib/wallet-provisioning";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? process.env.NEXT_PUBLIC_VERCEL_URL
: "http://localhost:3000";

export const signUpAction = async (formData: FormData) => {
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
Expand Down Expand Up @@ -61,29 +58,8 @@ export const signUpAction = async (formData: FormData) => {
}

try {
const createdWalletSetResponse = await fetch(`${baseUrl}/api/wallet-set`, {
method: "PUT",
body: JSON.stringify({
entityName: email,
}),
headers: {
"Content-Type": "application/json",
},
});

const createdWalletSet = await createdWalletSetResponse.json();

const createdWalletResponse = await fetch(`${baseUrl}/api/wallet`, {
method: "POST",
body: JSON.stringify({
walletSetId: createdWalletSet.id,
}),
headers: {
"Content-Type": "application/json",
},
});

const createdWallet = await createdWalletResponse.json();
const createdWalletSet = await createWalletSet(email);
const createdWallet = await createWallet(createdWalletSet.id);

const { data: profileData, error: profileError } = await supabase
.from("profiles")
Expand Down
11 changes: 11 additions & 0 deletions app/api/contracts/escrow/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ async function waitForTransactionStatus(id: string) {
export async function POST(req: NextRequest) {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body: CreateEscrowRequest = await req.json();

// Validate request
Expand Down Expand Up @@ -190,6 +195,12 @@ export async function POST(req: NextRequest) {

export async function GET(req: NextRequest) {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { searchParams } = new URL(req.url);
const id = searchParams.get("id");

Expand Down
22 changes: 10 additions & 12 deletions app/api/wallet-set/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@
*/

import { NextRequest, NextResponse } from "next/server";
import { circleDeveloperSdk } from "@/lib/utils/developer-controlled-wallets-client";
import { createSupabaseServerClient } from "@/lib/supabase/server-client";
import { createWalletSet } from "@/lib/wallet-provisioning";

export async function PUT(req: NextRequest) {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This auth check can break the existing onboarding wallet provisioning path.

signUpAction and /auth/callback call this endpoint with server-side fetch() and only a Content-Type header:

  • app/actions/index.ts calls /api/wallet-set, then /api/wallet
  • app/auth/callback/route.ts calls /api/wallet-set, then /api/wallet

This route now reads the Supabase user from request cookies via createSupabaseServerClient().auth.getUser(). I reproduced the relevant runtime boundary locally with Node server-side fetch(): a request built with only { "Content-Type": "application/json" } sends no Cookie header to the target server. With the current onboarding calls, this can hit the new 401 path before wallet-set creation. The same pattern applies to the new auth check in app/api/wallet/route.ts.

I would keep the endpoint auth hardening, but avoid routing the server-side onboarding flow through cookie-authenticated HTTP calls. A smaller fix would be to move wallet-set/wallet creation into a shared server helper used directly by sign-up/callback and by the API routes, or explicitly preserve the authenticated request cookies when these internal calls are made.

A regression test should cover: "new user sign-up/callback creates wallet set and wallet."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review, @Cassxbt! - You're absolutely right / the cookie-forwarding gap on server-side fetch() would have broken new-user onboarding. Good catch.

Refactored in 493a14a to follow your shared-helper suggestion:

lib/wallet-provisioning.ts (new) - single source of truth for Circle SDK wallet provisioning. Two exported functions:

  • createWalletSet(entityName) — wraps circleDeveloperSdk.createWalletSet + DB persist
  • createWallet(walletSetId, blockchain) — wraps circleDeveloperSdk.createWallets + DB persist

app/api/wallet-set/route.ts & app/api/wallet/route.ts

  • Auth check kept (for external callers)
  • Inline Circle SDK calls replaced with helper calls

app/actions/index.ts (signUpAction) & app/auth/callback/route.ts

  • Removed the internal fetch() calls to /api/wallet-set and /api/wallet
  • Replaced with direct helper invocations using the server-side context they already have

This way:

  • The onboarding flow no longer round-trips through HTTP, so the cookie issue is gone entirely
  • External callers to the API routes still get a proper 401 without a session
  • Single source of truth — both paths converge on the same helper

Net change: +61 / -84 (less code, no inline duplication).

For the regression test you mentioned ("new user sign-up/callback creates wallet set and wallet"): happy to add one in a follow-up PR if you'd like — I noticed the repo doesn't currently have an integration test setup, so I left that out of this scope to keep the PR focused. Let me know if you want it included here instead.

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { entityName } = await req.json();

if (!entityName.trim()) {
Expand All @@ -30,18 +37,9 @@ export async function PUT(req: NextRequest) {
);
}

const response = await circleDeveloperSdk.createWalletSet({
name: entityName,
});

if (!response.data) {
return NextResponse.json(
"The response did not include a valid wallet set",
{ status: 500 }
);
}
const walletSet = await createWalletSet(entityName);

return NextResponse.json({ ...response.data.walletSet }, { status: 201 });
return NextResponse.json({ ...walletSet }, { status: 201 });
} catch (error: any) {
console.error(`Wallet set creation failed: ${error.message}`);
return NextResponse.json(
Expand Down
7 changes: 7 additions & 0 deletions app/api/wallet/balance/request/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@
*/

import { type NextRequest, NextResponse } from "next/server";
import { createSupabaseServerClient } from "@/lib/supabase/server-client";

export async function POST(req: NextRequest) {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await req.json();

if (!body.walletAddress) {
Expand Down
26 changes: 26 additions & 0 deletions app/api/wallet/balance/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import { type NextRequest, NextResponse } from "next/server";
import { createSupabaseServerClient } from "@/lib/supabase/server-client";
import { circleDeveloperSdk } from "@/lib/utils/developer-controlled-wallets-client";
import { z } from "zod";

Expand All @@ -35,6 +36,12 @@ export async function POST(
req: NextRequest,
): Promise<NextResponse<WalletBalanceResponse>> {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await req.json();
const parseResult = WalletIdSchema.safeParse(body);

Expand All @@ -47,6 +54,25 @@ export async function POST(

const { walletId } = parseResult.data;

const { data: profile } = await supabase
.from("profiles")
.select("id")
.eq("auth_user_id", user.id)
.single();
if (!profile) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: walletRecord } = await supabase
.from("wallets")
.select("id")
.eq("circle_wallet_id", walletId)
.eq("profile_id", profile.id)
.single();
if (!walletRecord) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const response = await circleDeveloperSdk.getWalletTokenBalance({
id: walletId,
includeAll: true,
Expand Down
32 changes: 10 additions & 22 deletions app/api/wallet/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { Blockchain } from "@circle-fin/smart-contract-platform";
import { NextRequest, NextResponse } from "next/server";
import { circleDeveloperSdk } from "@/lib/utils/developer-controlled-wallets-client";
import { createSupabaseServerClient } from "@/lib/supabase/server-client";
import { createWallet } from "@/lib/wallet-provisioning";

export async function POST(req: NextRequest) {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { walletSetId } = await req.json();

if (!walletSetId) {
Expand All @@ -31,27 +37,9 @@ export async function POST(req: NextRequest) {
);
}

if (!process.env.CIRCLE_BLOCKCHAIN) {
throw new Error("CIRCLE_BLOCKCHAIN environment variable is not set");
}

const response = await circleDeveloperSdk.createWallets({
accountType: "SCA",
blockchains: [process.env.CIRCLE_BLOCKCHAIN as Blockchain],
count: 1,
walletSetId,
});

if (!response.data?.wallets?.length) {
return NextResponse.json(
{ error: "No wallets were created" },
{ status: 500 }
);
}

const [createdWallet] = response.data.wallets;
const wallet = await createWallet(walletSetId);

return NextResponse.json(createdWallet, { status: 201 });
return NextResponse.json(wallet, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
Expand Down
26 changes: 26 additions & 0 deletions app/api/wallet/transactions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import { type NextRequest, NextResponse } from "next/server";
import { createSupabaseServerClient } from "@/lib/supabase/server-client";
import { circleDeveloperSdk } from "@/lib/utils/developer-controlled-wallets-client";
import { z } from "zod";

Expand Down Expand Up @@ -48,6 +49,31 @@ export async function GET(
{ params }: { params: { id: string } },
): Promise<NextResponse<TransactionResponse>> {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: profile } = await supabase
.from("profiles")
.select("id")
.eq("auth_user_id", user.id)
.single();
if (!profile) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: txRecord } = await supabase
.from("transactions")
.select("id")
.eq("circle_transaction_id", params.id)
.eq("profile_id", profile.id)
.single();
if (!txRecord) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

// Validate the transaction ID is a Circle's transaction IDs
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
Expand Down
26 changes: 26 additions & 0 deletions app/api/wallet/transactions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import { type NextRequest, NextResponse } from "next/server";
import { createSupabaseServerClient } from "@/lib/supabase/server-client";
import { circleDeveloperSdk } from "@/lib/utils/developer-controlled-wallets-client";
import { z } from "zod";

Expand Down Expand Up @@ -52,6 +53,12 @@ export async function POST(
req: NextRequest,
): Promise<NextResponse<WalletTransactionsResponse>> {
try {
const supabase = createSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await req.json();
const parseResult = WalletIdSchema.safeParse(body);

Expand All @@ -64,6 +71,25 @@ export async function POST(

const { walletId } = parseResult.data;

const { data: profile } = await supabase
.from("profiles")
.select("id")
.eq("auth_user_id", user.id)
.single();
if (!profile) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: walletRecord } = await supabase
.from("wallets")
.select("id")
.eq("circle_wallet_id", walletId)
.eq("profile_id", profile.id)
.single();
if (!walletRecord) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const response = await circleDeveloperSdk.listTransactions({
walletIds: [walletId],
includeAll: true,
Expand Down
26 changes: 3 additions & 23 deletions app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import { createSupabaseServerClient } from "@/lib/supabase/server-client";
import { createWalletSet, createWallet } from "@/lib/wallet-provisioning";
import { NextResponse } from "next/server";

const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
Expand Down Expand Up @@ -61,29 +62,8 @@ export async function GET(request: Request) {
return NextResponse.redirect(`${baseUrl}/${nextUrl}`);
}

const createdWalletSetResponse = await fetch(`${baseUrl}/api/wallet-set`, {
method: "PUT",
body: JSON.stringify({
entityName: data.user.email,
}),
headers: {
"Content-Type": "application/json",
},
});

const createdWalletSet = await createdWalletSetResponse.json();

const createdWalletResponse = await fetch(`${baseUrl}/api/wallet`, {
method: "POST",
body: JSON.stringify({
walletSetId: createdWalletSet.id,
}),
headers: {
"Content-Type": "application/json",
},
});

const createdWallet = await createdWalletResponse.json();
const createdWalletSet = await createWalletSet(data.user.email!);
const createdWallet = await createWallet(createdWalletSet.id);

await supabase
.schema("public")
Expand Down
Loading