Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/app/(dashboard)/wallet/transfer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ function WalletTransferContent() {
</div>
)}
</div>
}
)
}

export default function WalletTransferPage() {
Expand Down
64 changes: 64 additions & 0 deletions src/app/(dashboard)/wallet/withdraw/steps/otp-step.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import { OtpStep } from "./otp-step"

describe("OtpStep Component", () => {
it("renders OTP input and triggers onResend when Resend button is clicked", () => {
const onResend = vi.fn()
const onVerify = vi.fn()
const onOtpChange = vi.fn()

render(
<OtpStep
otp={["1", "2", "3", "4", "5", "6"]}
loading={false}
errMsg=""
resendCooldown={0}
onOtpChange={onOtpChange}
onVerify={onVerify}
onResend={onResend}
/>
)

const resendBtn = screen.getByRole("button", { name: /^resend/i })
expect(resendBtn).not.toBeDisabled()

fireEvent.click(resendBtn)
expect(onResend).toHaveBeenCalledTimes(1)
})

it("disables Resend button during cooldown and displays countdown", () => {
render(
<OtpStep
otp={["", "", "", "", "", ""]}
loading={false}
errMsg=""
resendCooldown={45}
onOtpChange={vi.fn()}
onVerify={vi.fn()}
onResend={vi.fn()}
/>
)

const resendBtn = screen.getByRole("button", { name: /resend in 45s/i })
expect(resendBtn).toBeDisabled()
})

it("disables Resend button during active loading state", () => {
render(
<OtpStep
otp={["1", "2", "3", "4", "5", "6"]}
loading={true}
errMsg=""
resendCooldown={0}
onOtpChange={vi.fn()}
onVerify={vi.fn()}
onResend={vi.fn()}
/>
)

const resendBtn = screen.getByRole("button", { name: /^resend/i })
expect(resendBtn).toBeDisabled()
})
})
113 changes: 113 additions & 0 deletions src/app/api/claim-name/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { POST, _resetClaimNameState } from "./route";

describe("POST /api/claim-name", () => {
beforeEach(() => {
_resetClaimNameState();
});

it("claims a valid name for an authenticated user", async () => {
const req = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer user-100",
},
body: JSON.stringify({ name: " Satoshi_N " }),
});

const res = await POST(req);
expect(res.status).toBe(201);

const data = await res.json();
expect(data.success).toBe(true);
expect(data.name).toBe("satoshi_n");
expect(data.claimedBy).toBe("user-100");
});

it("rejects unauthenticated requests", async () => {
const req = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "valid_name" }),
});

const res = await POST(req);
expect(res.status).toBe(401);

const data = await res.json();
expect(data.error).toMatch(/Authentication required/i);
});

it("rejects invalid or malformed names", async () => {
// Too short (< 3 chars)
const reqShort = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: { Authorization: "Bearer user-100" },
body: JSON.stringify({ name: "ab" }),
});
const resShort = await POST(reqShort);
expect(resShort.status).toBe(400);

// Illegal characters
const reqIllegal = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: { Authorization: "Bearer user-100" },
body: JSON.stringify({ name: "satoshi<script>" }),
});
const resIllegal = await POST(reqIllegal);
expect(resIllegal.status).toBe(400);
});

it("prevents name squatting by another account (ownership binding)", async () => {
const req1 = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: { Authorization: "Bearer owner-user" },
body: JSON.stringify({ name: "unique_handle" }),
});
await POST(req1);

// Second user attempts to claim the same handle
const req2 = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: { Authorization: "Bearer attacker-user" },
body: JSON.stringify({ name: "unique_handle" }),
});
const res2 = await POST(req2);
expect(res2.status).toBe(409);

const data2 = await res2.json();
expect(data2.error).toMatch(/already claimed by another account/i);
});

it("enforces server-side rate limits (max 5 per minute)", async () => {
for (let i = 0; i < 5; i++) {
const req = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: {
"x-forwarded-for": "203.0.113.195",
Authorization: "Bearer user-1",
},
body: JSON.stringify({ name: `handle_${i + 1}` }),
});
const res = await POST(req);
expect(res.status).toBe(201);
}

// 6th attempt from same IP triggers rate limiting
const req6 = new NextRequest("http://localhost/api/claim-name", {
method: "POST",
headers: {
"x-forwarded-for": "203.0.113.195",
Authorization: "Bearer user-1",
},
body: JSON.stringify({ name: "handle_6" }),
});
const res6 = await POST(req6);
expect(res6.status).toBe(429);

const data6 = await res6.json();
expect(data6.error).toMatch(/Rate limit exceeded/i);
});
});
133 changes: 133 additions & 0 deletions src/app/api/claim-name/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/session-cookies";

// In-memory rate limiting map for name claims: IP -> timestamps[]
const rateLimitMap = new Map<string, number[]>();
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const MAX_CLAIMS_PER_WINDOW = 5;

// In-memory claimed names store: canonicalName -> { userId: string, claimedAt: number }
const claimedNames = new Map<string, { userId: string; claimedAt: number }>();

export function sanitizeAndCanonicalizeName(name: string): string | null {
if (typeof name !== "string") return null;
const trimmed = name.trim().toLowerCase();
// Name rules: 3-30 characters, alphanumeric, underscores, hyphens
if (!/^[a-z0-9_-]{3,30}$/.test(trimmed)) {
return null;
}
return trimmed;
}

export async function getServerSession(request: NextRequest): Promise<{ user: { id: string } } | null> {
const authHeader = request.headers.get("authorization");
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.replace("Bearer ", "").trim();
if (token) return { user: { id: token } };
}
const cookie =
request.cookies.get(ACCESS_TOKEN_COOKIE)?.value ||
request.cookies.get("moistello_session")?.value ||
request.cookies.get("user_id")?.value;
if (cookie) {
return { user: { id: cookie } };
}
return null;
}

function checkRateLimit(ip: string): boolean {
const now = Date.now();
const timestamps = rateLimitMap.get(ip) ?? [];
const validTimestamps = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);

if (validTimestamps.length >= MAX_CLAIMS_PER_WINDOW) {
return false;
}

validTimestamps.push(now);
rateLimitMap.set(ip, validTimestamps);
return true;
}

export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "127.0.0.1";

// 1. Rate Limiting Check
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: "Rate limit exceeded. Please wait before claiming another name." },
{ status: 429 }
);
}

let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const rawName = body.name ?? body.displayName;
if (typeof rawName !== "string") {
return NextResponse.json({ error: "Missing required 'name' field" }, { status: 400 });
}

// 2. Input Validation + Canonicalization
const canonicalName = sanitizeAndCanonicalizeName(rawName);
if (!canonicalName) {
return NextResponse.json(
{
error:
"Invalid name format. Names must be 3-30 characters long and contain only letters, numbers, underscores, or hyphens.",
},
{ status: 400 }
);
}

// 3. Ownership Binding Check
const session = await getServerSession(request);
const userId = session?.user?.id ?? (typeof body.userId === "string" ? body.userId : null);

if (!userId || typeof userId !== "string") {
return NextResponse.json(
{ error: "Authentication required to claim a display name" },
{ status: 401 }
);
}

const existingClaim = claimedNames.get(canonicalName);
if (existingClaim) {
if (existingClaim.userId !== userId) {
return NextResponse.json(
{ error: "Name is already claimed by another account" },
{ status: 409 }
);
}
// Idempotent re-claim by same owner
return NextResponse.json({
success: true,
name: canonicalName,
claimedBy: userId,
status: "re-claimed",
});
}

// Record binding
claimedNames.set(canonicalName, { userId, claimedAt: Date.now() });

return NextResponse.json(
{
success: true,
name: canonicalName,
claimedBy: userId,
status: "claimed",
},
{ status: 201 }
);
}

// Reset helper for unit testing
export function _resetClaimNameState() {
rateLimitMap.clear();
claimedNames.clear();
}
3 changes: 2 additions & 1 deletion src/lib/wallet/wc2-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getWC2SessionStore } from "./wc2-session-store"
import { getSignClientClass } from "./wc2-sign-client"
import { WC2_QR_EXPIRATION_MS } from "@/lib/constants"
import { computeSessionExpiry } from "./session-lifecycle"
import { validateStellarAddress } from "@/lib/stellar/validate-address"

const PROJECT_ID = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || ""
const RELAY_URL = "wss://relay.walletconnect.com"
Expand Down Expand Up @@ -63,7 +64,7 @@ class WCSessionOrchestrator {
}

private isValidStellarPublicKey(key: string): boolean {
return /^G[A-Z0-9]{55}$/.test(key)
return validateStellarAddress(key)
}

private chainIdForNetwork(network: NetworkType): string {
Expand Down