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
115 changes: 115 additions & 0 deletions src/__tests__/trpc/userWalletsAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";

import { makeAnonymousCtx, makeSessionCtx, makeWalletCtx } from "./helpers";

jest.mock("superjson", () => ({
__esModule: true,
default: {
serialize: (value: unknown) => value,
deserialize: (value: unknown) => value,
},
}));

jest.mock("@/server/auth", () => ({
__esModule: true,
getServerAuthSession: jest.fn(),
}));

let createCaller: typeof import("@/server/api/root").createCaller;

const SIGNER = "addr_test1signer";
const OTHER = "addr_test1other";

const walletRow = {
id: "wallet-1",
name: "Treasury",
description: "Board treasury",
signersAddresses: [SIGNER, OTHER],
signersDescriptions: ["Alice", "Bob"],
};

const makeMockDb = () => ({
wallet: {
findMany: jest.fn(async (_args: unknown) => [walletRow]),
},
});

/**
* wallet.getUserWallets lists every wallet that names an address as a signer.
* Signer addresses are public on-chain, so the address alone proves nothing:
* the caller must hold a session for it.
*/
describe("wallet.getUserWallets authorization", () => {
beforeAll(async () => {
({ createCaller } = await import("@/server/api/root"));
});

beforeEach(() => {
jest.clearAllMocks();
});

it("refuses an anonymous caller without touching the database", async () => {
const db = makeMockDb();
const caller = createCaller(makeAnonymousCtx(db) as any);

await expect(
caller.wallet.getUserWallets({ address: SIGNER }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });

expect(db.wallet.findMany).not.toHaveBeenCalled();
});

it("refuses a wallet session asking for someone else's address", async () => {
const db = makeMockDb();
const caller = createCaller(makeWalletCtx(OTHER, db) as any);

await expect(
caller.wallet.getUserWallets({ address: SIGNER }),
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(db.wallet.findMany).not.toHaveBeenCalled();
});

it("refuses a NextAuth session asking for someone else's address", async () => {
const db = makeMockDb();
const caller = createCaller(makeSessionCtx(OTHER, db) as any);

await expect(
caller.wallet.getUserWallets({ address: SIGNER }),
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(db.wallet.findMany).not.toHaveBeenCalled();
});

it("lists the wallets of the address the wallet session holds", async () => {
const db = makeMockDb();
const caller = createCaller(makeWalletCtx(SIGNER, db) as any);

await expect(
caller.wallet.getUserWallets({ address: SIGNER }),
).resolves.toEqual([walletRow]);

expect(db.wallet.findMany).toHaveBeenCalledWith({
where: { signersAddresses: { has: SIGNER } },
});
});

it("lists the wallets for any address in a multi-wallet session", async () => {
const db = makeMockDb();
const ctx = { ...makeWalletCtx(OTHER, db), sessionWallets: [OTHER, SIGNER] };
const caller = createCaller(ctx as any);

await expect(
caller.wallet.getUserWallets({ address: SIGNER }),
).resolves.toEqual([walletRow]);
});

it("lists the wallets of a NextAuth user's own address", async () => {
const db = makeMockDb();
const caller = createCaller(makeSessionCtx(SIGNER, db) as any);

await expect(
caller.wallet.getUserWallets({ address: SIGNER }),
).resolves.toEqual([walletRow]);
});
});
53 changes: 43 additions & 10 deletions src/components/pages/homepage/wallets/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,32 @@ import { ImportTransferDialog } from "./import-transfer-dialog";
import BotManagementCard from "@/components/pages/user/BotManagementCard";


/**
* Shown to a connected wallet that has not signed the session nonce yet. The
* wallet list is only served to a session for the address, so without this
* the page would claim the user has no wallets at all.
*/
function AuthorizeToSeeWallets() {
const requestReauth = useUserStore((state) => state.requestReauth);
return (
<div className="col-span-full rounded-lg border border-dashed p-6 text-center">
<p className="text-sm text-muted-foreground">
Authorize your connected wallet to see the multisig wallets it signs
for.
</p>
<Button className="mt-4" onClick={() => requestReauth()}>
Authorize wallet
</Button>
</div>
);
}

export default function PageWallets() {
const { wallets, isLoading: isLoadingWallets } = useUserWallets();
const {
wallets,
isLoading: isLoadingWallets,
needsAuthorization,
} = useUserWallets();
const [showArchived, setShowArchived] = useState(false);
const userAddress = useUserStore((state) => state.userAddress);
const address = userAddress ?? "";
Expand All @@ -43,18 +67,17 @@ export default function PageWallets() {
refetchOnWindowFocus: false,
},
);
// Both wallet-creation lists are protectedProcedures: without a session for
// this address they can only answer 401/403.
const isAuthorized = walletSession?.authorized ?? false;
const sessionWallets = walletSession?.wallets ?? [];
// If there's no wallet-session constraint, public read queries can run without authorization
const canQueryPublicWalletData = sessionWallets.length === 0 || isAuthorized;

const { data: newPendingWallets, isLoading: isLoadingNewWallets } = api.wallet.getUserNewWallets.useQuery(
{ address },
{
// Only enable query when user is authorized (prevents 403 errors)
enabled: address.length > 0 && canQueryPublicWalletData,
enabled: address.length > 0 && isAuthorized,
retry: (failureCount, error) => {
// Don't retry on authorization errors (403)
// Don't retry on authorization errors (401/403)
if (error && typeof error === "object") {
const err = error as {
code?: string;
Expand All @@ -65,8 +88,11 @@ export default function PageWallets() {
const errorMessage = err.message || err.shape?.message || "";
const isAuthError =
err.code === "FORBIDDEN" ||
err.code === "UNAUTHORIZED" ||
err.data?.code === "FORBIDDEN" ||
err.data?.code === "UNAUTHORIZED" ||
err.data?.httpStatus === 403 ||
err.data?.httpStatus === 401 ||
err.shape?.code === "FORBIDDEN" ||
errorMessage.includes("Address mismatch");
if (isAuthError) return false;
Expand All @@ -81,9 +107,9 @@ export default function PageWallets() {
{ address },
{
// Only enable query when user is authorized (prevents 403 errors)
enabled: address.length > 0 && canQueryPublicWalletData,
enabled: address.length > 0 && isAuthorized,
retry: (failureCount, error) => {
// Don't retry on authorization errors (403)
// Don't retry on authorization errors (401/403)
if (error && typeof error === "object") {
const err = error as {
code?: string;
Expand All @@ -94,8 +120,11 @@ export default function PageWallets() {
const errorMessage = err.message || err.shape?.message || "";
const isAuthError =
err.code === "FORBIDDEN" ||
err.code === "UNAUTHORIZED" ||
err.data?.code === "FORBIDDEN" ||
err.data?.code === "UNAUTHORIZED" ||
err.data?.httpStatus === 403 ||
err.data?.httpStatus === 401 ||
err.shape?.code === "FORBIDDEN" ||
errorMessage.includes("Address mismatch");
if (isAuthError) return false;
Expand Down Expand Up @@ -147,9 +176,13 @@ export default function PageWallets() {
<WalletCardSkeleton />
</>
)}
{!isLoadingWallets && address.length > 0 && (!wallets || wallets.length === 0) && (
<EmptyWalletsState />
{!isLoadingWallets && needsAuthorization && (
<AuthorizeToSeeWallets />
)}
{!isLoadingWallets &&
!needsAuthorization &&
address.length > 0 &&
(!wallets || wallets.length === 0) && <EmptyWalletsState />}
{!isLoadingWallets && wallets &&
wallets
.filter((wallet) => showArchived || !wallet.isArchived)
Expand Down
60 changes: 44 additions & 16 deletions src/hooks/useUserWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,32 @@ export default function useUserWallets() {
const address = userAddress ?? "";

// Check wallet session authorization before enabling queries
const { data: walletSession } = api.auth.getWalletSession.useQuery(
{ address },
{
enabled: address.length > 0,
refetchOnWindowFocus: false,
},
);
const { data: walletSession, isError: sessionCheckFailed } =
api.auth.getWalletSession.useQuery(
{ address },
{
enabled: address.length > 0,
refetchOnWindowFocus: false,
},
);
// The wallet list is only served to a caller with a session for `address`
// (the server answers 401/403 otherwise), so wait for the authorization
// step instead of firing a request that is bound to fail. The layout
// refetches the session after authorizing, which flips this on.
const isAuthorized = walletSession?.authorized ?? false;
const sessionWallets = walletSession?.wallets ?? [];
// If there's no wallet-session constraint, public read queries can run without authorization
const canQueryPublicWalletData = sessionWallets.length === 0 || isAuthorized;

const { data: wallets, isLoading } = api.wallet.getUserWallets.useQuery(

const {
data: wallets,
isLoading,
error: walletsError,
} = api.wallet.getUserWallets.useQuery(
{ address },
{
// Only enable query when user is authorized (prevents 403 errors)
enabled: address.length > 0 && canQueryPublicWalletData,
enabled: address.length > 0 && isAuthorized,
staleTime: 1 * 60 * 1000, // 1 minute (user/wallet data)
gcTime: 5 * 60 * 1000, // 5 minutes
retry: (failureCount, error) => {
// Don't retry on authorization errors (403)
// Don't retry on authorization errors (401/403)
if (error && typeof error === "object") {
const err = error as {
code?: string;
Expand All @@ -42,8 +47,11 @@ export default function useUserWallets() {
const errorMessage = err.message || err.shape?.message || "";
const isAuthError =
err.code === "FORBIDDEN" ||
err.code === "UNAUTHORIZED" ||
err.data?.code === "FORBIDDEN" ||
err.data?.code === "UNAUTHORIZED" ||
err.data?.httpStatus === 403 ||
err.data?.httpStatus === 401 ||
err.shape?.code === "FORBIDDEN" ||
errorMessage.includes("Address mismatch");
if (isAuthError) return false;
Expand All @@ -61,5 +69,25 @@ export default function useUserWallets() {
.map((wallet) => buildWallet(wallet, network));
}, [wallets, network]);

return { wallets: mappedWallets, isLoading };
// Until the session question is answered the list is effectively loading;
// once it is answered "no", callers should ask the user to authorize rather
// than render "you have no wallets".
// A failed session check is not a "no": the auth modal only opens on an
// answer, so prompting then would offer a button that does nothing. A list
// request refused with 401 is a "no" (the cookie expired since the session
// check was cached).
const isCheckingSession =
address.length > 0 && walletSession === undefined && !sessionCheckFailed;
const listRefused =
walletsError?.data?.code === "UNAUTHORIZED" ||
walletsError?.data?.httpStatus === 401;
const needsAuthorization =
address.length > 0 &&
((walletSession !== undefined && !isAuthorized) || listRefused);

return {
wallets: mappedWallets,
isLoading: isLoading || isCheckingSession,
needsAuthorization,
};
}
16 changes: 12 additions & 4 deletions src/server/api/routers/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,14 @@ function walletDataFromPayload(
}

export const walletRouter = createTRPCRouter({
// Read operations stay public but validate signer membership by address param
// Lists every wallet that names `address` as a signer, so the caller must
// have proved control of that address (wallet session or NextAuth user).
//
// This used to skip the check when there was no session at all, which let
// anyone look up any address — signer addresses are public on-chain — and
// read back full wallet rows: names, descriptions, the signers' own labels
// and import bodies. getWallet already refuses anonymous callers; the list
// must not hand out more than the single-wallet read does.
getUserWallets: publicProcedure
.input(z.object({ address: z.string() }))
.query(async ({ ctx, input }) => {
Expand All @@ -233,9 +240,10 @@ export const walletRouter = createTRPCRouter({
: ctx.sessionAddress
? [ctx.sessionAddress]
: [];
// If user has an active session, validate that the requested address is authorized
// Throw error if address doesn't match (security: prevent unauthorized access)
if (addresses.length > 0 && !addresses.includes(input.address)) {
if (addresses.length === 0) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!addresses.includes(input.address)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Address mismatch" });
}
// Query wallets where the user is a signer
Expand Down
Loading