From 91313b0be66fcadab95bc9cd0ff7b095eeb0d98a Mon Sep 17 00:00:00 2001 From: Vincent ibochi <290086463+ibochivincent-lang@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:12:06 +0100 Subject: [PATCH 1/2] feat(web): add a protected mutation to unlink a wallet from the dashboard Wallets could be listed but never removed, so a rotated deploy key or a wrongly-linked wallet kept contributing to a profile permanently. Add account.unlinkWallet, gated by the same session + same-origin guard as every other mutation, which refuses the primary handle wallet (that's a registry operation) and refuses a wallet bound to a different profile with the same "not found" message a nonexistent pubkey gets. The dashboard gets a two-step Unlink/Confirm control per non-primary wallet. --- apps/web/app/(dashboard)/app/wallets/page.tsx | 25 ++++-- .../app/wallets/unlink-wallet-button.tsx | 83 +++++++++++++++++++ apps/web/lib/server/account.test.ts | 64 +++++++++++++- apps/web/lib/server/account.ts | 58 +++++++++++++ apps/web/lib/server/trpc.test.ts | 51 ++++++++++++ apps/web/lib/server/trpc.ts | 10 ++- 6 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx diff --git a/apps/web/app/(dashboard)/app/wallets/page.tsx b/apps/web/app/(dashboard)/app/wallets/page.tsx index 0b4a022..1e6e851 100644 --- a/apps/web/app/(dashboard)/app/wallets/page.tsx +++ b/apps/web/app/(dashboard)/app/wallets/page.tsx @@ -2,6 +2,7 @@ import { currentAddress } from '@/lib/server/session'; import { getAccountWallets } from '@/lib/server/account'; import { isRegistryConfigured, lookupWallet } from '@/lib/server/registry-read'; import { stellarExpertAccountUrl } from '@/lib/network'; +import { UnlinkWalletButton } from './unlink-wallet-button'; function truncate(a: string): string { return a.length > 18 ? `${a.slice(0, 8)}…${a.slice(-6)}` : a; @@ -69,15 +70,21 @@ export default async function WalletsPage() { {w.source === 'onchain' ? '● on-chain' : '○ curated'} - - Explorer ↗ - +
+ + Explorer ↗ + + {/* The primary wallet is the handle's on-chain claim; unlinking + it is a registry operation (release/transfer), not a + dashboard edit, so no button is offered for it here. */} + {!w.isPrimary && } +
)) )} diff --git a/apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx b/apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx new file mode 100644 index 0000000..96c5646 --- /dev/null +++ b/apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { trpc } from '@/lib/trpc-client'; + +type State = { status: 'idle' | 'confirming' | 'busy' } | { status: 'error'; message: string }; + +const mono = { fontFamily: 'var(--font-mono)' } as const; + +/** + * Removes a non-primary wallet from the caller's own profile. Two-step + * confirm (Unlink → Confirm/Cancel) instead of a native `confirm()` dialog, to + * match the rest of the dashboard's styling. The primary wallet is never + * offered this button — see `wallets/page.tsx` — since unlinking it is a + * registry operation the server refuses anyway. + */ +export function UnlinkWalletButton({ pubkey }: { pubkey: string }) { + const router = useRouter(); + const [state, setState] = useState({ status: 'idle' }); + + async function confirmUnlink() { + setState({ status: 'busy' }); + try { + await trpc.account.unlinkWallet.mutate({ wallet: pubkey }); + router.refresh(); + } catch (err) { + setState({ + status: 'error', + message: err instanceof Error ? err.message : 'Could not unlink wallet', + }); + } + } + + if (state.status === 'confirming') { + return ( + + Unlink this wallet? + + + + ); + } + + if (state.status === 'error') { + return ( + + {state.message} + + + ); + } + + return ( + + ); +} diff --git a/apps/web/lib/server/account.test.ts b/apps/web/lib/server/account.test.ts index adbd732..62b7649 100644 --- a/apps/web/lib/server/account.test.ts +++ b/apps/web/lib/server/account.test.ts @@ -1,7 +1,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { nativeToScVal, type Transaction } from '@stellar/stellar-sdk'; -import { getAccount, normalizeAccountUpdate } from './account.ts'; +import { getAccount, normalizeAccountUpdate, unlinkWallet, type WalletStore } from './account.ts'; import type { SimulatingServer } from './registry-read.ts'; // These tests run without DATABASE_URL, which is exactly the configuration the @@ -92,3 +92,65 @@ test('normalizeAccountUpdate rejects an over-long bio', () => { /280 characters or fewer/, ); }); + +// ─── unlinkWallet ─────────────────────────────────────────────────────────── + +/** In-memory WalletStore backed by a plain map, keyed by pubkey. */ +function fakeWalletStore( + rows: Record, +): { store: WalletStore; deleted: string[] } { + const deleted: string[] = []; + const store: WalletStore = { + wallet: { + findUnique: async ({ where: { pubkey } }) => rows[pubkey] ?? null, + delete: async ({ where: { pubkey } }) => { + deleted.push(pubkey); + }, + }, + }; + return { store, deleted }; +} + +test('unlinkWallet requires a configured database', async () => { + await assert.rejects(() => unlinkWallet(WALLET, 'GOTHER'), /database/i); +}); + +test('unlinkWallet refuses a caller with no profile of their own', async () => { + const { store } = fakeWalletStore({}); + await assert.rejects(() => unlinkWallet(WALLET, 'GOTHER', store), /No profile is bound/); +}); + +test('unlinkWallet refuses a wallet that does not exist', async () => { + const { store } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + }); + await assert.rejects(() => unlinkWallet(WALLET, 'GMISSING', store), /not found/i); +}); + +test('unlinkWallet refuses a wallet bound to a different profile', async () => { + const { store, deleted } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + GOTHERSPROFILE: { profileId: 'p2', isPrimary: false }, + }); + // Same "not found" message as a nonexistent pubkey — the caller must not be + // able to tell "doesn't exist" from "belongs to someone else". + await assert.rejects(() => unlinkWallet(WALLET, 'GOTHERSPROFILE', store), /not found/i); + assert.equal(deleted.length, 0); +}); + +test('unlinkWallet refuses the primary wallet', async () => { + const { store, deleted } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + }); + await assert.rejects(() => unlinkWallet(WALLET, WALLET, store), /primary/i); + assert.equal(deleted.length, 0); +}); + +test('unlinkWallet deletes a non-primary wallet on the caller\'s own profile', async () => { + const { store, deleted } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + GSECOND: { profileId: 'p1', isPrimary: false }, + }); + await unlinkWallet(WALLET, 'GSECOND', store); + assert.deepEqual(deleted, ['GSECOND']); +}); diff --git a/apps/web/lib/server/account.ts b/apps/web/lib/server/account.ts index bd00a80..310eb8d 100644 --- a/apps/web/lib/server/account.ts +++ b/apps/web/lib/server/account.ts @@ -162,3 +162,61 @@ export async function updateAccount(address: string, update: AccountUpdate): Pro editable: true, }; } + +/** + * Minimal slice of the Prisma client `unlinkWallet` touches. Declaring it as + * an interface (mirroring the indexer worker stores) lets tests inject a + * lightweight mock instead of depending on a real database, which is how the + * cross-profile refusal below is exercised. + */ +export interface WalletStore { + wallet: { + findUnique(args: { + where: { pubkey: string }; + select: { profileId: true; isPrimary: true }; + }): Promise<{ profileId: string; isPrimary: boolean } | null>; + delete(args: { where: { pubkey: string } }): Promise; + }; +} + +/** + * Remove a wallet binding from the signed-in account's own profile. + * + * Two refusals guard this: the primary wallet is the handle→wallet claim + * itself, so removing it is a registry operation (release/transfer on-chain), + * never a dashboard edit; and a wallet bound to a *different* profile is + * refused with the same "not found" message a nonexistent pubkey gets, so one + * signed-in wallet can never delete — or even confirm the existence of — + * another profile's binding. + */ +export async function unlinkWallet( + address: string, + pubkey: string, + store?: WalletStore, +): Promise { + const db = store ?? ((await getPrisma()) as unknown as WalletStore | null); + if (!db) { + throw new Error('Wallet unlinking requires a configured database'); + } + + const caller = await db.wallet.findUnique({ + where: { pubkey: address }, + select: { profileId: true, isPrimary: true }, + }); + if (!caller) { + throw new Error('No profile is bound to this wallet yet — claim a handle on-chain first'); + } + + const target = await db.wallet.findUnique({ + where: { pubkey }, + select: { profileId: true, isPrimary: true }, + }); + if (!target || target.profileId !== caller.profileId) { + throw new Error('Wallet not found'); + } + if (target.isPrimary) { + throw new Error('Cannot unlink the primary wallet — releasing it is a registry operation'); + } + + await db.wallet.delete({ where: { pubkey } }); +} diff --git a/apps/web/lib/server/trpc.test.ts b/apps/web/lib/server/trpc.test.ts index eb692b7..1f118f2 100644 --- a/apps/web/lib/server/trpc.test.ts +++ b/apps/web/lib/server/trpc.test.ts @@ -85,6 +85,41 @@ test('account.update without a database surfaces a clear error', async () => { await assert.rejects(() => c.account.update({ displayName: 'Ada', bio: 'hi' }), /database/i); }); +test('account.unlinkWallet is rejected without a session', async () => { + __resetRateLimit(); + await assert.rejects( + () => + caller('10.0.2.5').account.unlinkWallet({ + wallet: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + }), + /Unauthorized|Cross-origin/, + ); +}); + +test('account.unlinkWallet rejects a malformed wallet address', async () => { + __resetRateLimit(); + const c = authedCaller('10.0.2.6', 'GTESTADDRESS', { + host: 'localhost', + origin: 'http://localhost', + }); + await assert.rejects(() => c.account.unlinkWallet({ wallet: 'not-a-valid-address' })); +}); + +test('account.unlinkWallet without a database surfaces a clear error', async () => { + __resetRateLimit(); + const c = authedCaller('10.0.2.7', 'GTESTADDRESS', { + host: 'localhost', + origin: 'http://localhost', + }); + await assert.rejects( + () => + c.account.unlinkWallet({ + wallet: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + }), + /database/i, + ); +}); + test('rate limiter blocks a caller after the window max', async () => { __resetRateLimit(); const c = caller('10.0.0.99'); @@ -188,6 +223,22 @@ test('a cross-origin mutation is FORBIDDEN', async () => { ); }); +test('a cross-origin unlinkWallet mutation is FORBIDDEN', async () => { + __resetRateLimit(); + const c = authedCaller('10.0.3.6', 'GTESTADDRESS', { + host: 'signet.dev', + origin: 'https://evil.example', + }); + assert.equal( + await codeOf(() => + c.account.unlinkWallet({ + wallet: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + }), + ), + 'FORBIDDEN', + ); +}); + test('malformed input is BAD_REQUEST', async () => { __resetRateLimit(); assert.equal( diff --git a/apps/web/lib/server/trpc.ts b/apps/web/lib/server/trpc.ts index e10f490..6597380 100644 --- a/apps/web/lib/server/trpc.ts +++ b/apps/web/lib/server/trpc.ts @@ -4,7 +4,7 @@ import { logger } from '../logger.ts'; import { rateLimit } from '../rate-limit.ts'; import { verifySession, SESSION_COOKIE } from '../auth.ts'; import { clientIp, isSameOriginHeaders } from '../security.ts'; -import { getAccount, updateAccount, normalizeAccountUpdate } from './account.ts'; +import { getAccount, unlinkWallet, updateAccount, normalizeAccountUpdate } from './account.ts'; import { boundCount, lookupWallet, resolveHandle } from './registry-read.ts'; /** @@ -133,6 +133,14 @@ const accountRouter = router({ update: protectedProcedure .input(normalizeAccountUpdate) .mutation(({ ctx, input }) => updateAccount(ctx.address, input)), + + // Removes a wallet from the caller's own profile. `unlinkWallet` itself + // refuses the primary wallet and any wallet bound to a different profile + // (see account.ts); `protectedProcedure` supplies the session + same-origin + // guard every other mutation here gets. + unlinkWallet: protectedProcedure + .input(walletInput) + .mutation(({ ctx, input }) => unlinkWallet(ctx.address, input.wallet)), }); /** From b7a9f946d8531cab3d4028aa4b33d7d7307ae55a Mon Sep 17 00:00:00 2001 From: blockchain-maxis <267648998+blockchain-maxis@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:23:04 +0100 Subject: [PATCH 2/2] style: prettier the files this PR touches --- apps/web/app/(dashboard)/app/wallets/page.tsx | 10 ++++++++-- apps/web/lib/server/account.test.ts | 9 +++++---- apps/web/lib/server/trpc.test.ts | 9 +++------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(dashboard)/app/wallets/page.tsx b/apps/web/app/(dashboard)/app/wallets/page.tsx index 75ba3aa..b722bf8 100644 --- a/apps/web/app/(dashboard)/app/wallets/page.tsx +++ b/apps/web/app/(dashboard)/app/wallets/page.tsx @@ -36,7 +36,10 @@ export default async function WalletsPage() { return (
-

+

Wallets

@@ -65,7 +68,10 @@ export default async function WalletsPage() { {truncate(w.pubkey)} {w.isPrimary && ( - + Primary )} diff --git a/apps/web/lib/server/account.test.ts b/apps/web/lib/server/account.test.ts index 62b7649..fc1f49e 100644 --- a/apps/web/lib/server/account.test.ts +++ b/apps/web/lib/server/account.test.ts @@ -96,9 +96,10 @@ test('normalizeAccountUpdate rejects an over-long bio', () => { // ─── unlinkWallet ─────────────────────────────────────────────────────────── /** In-memory WalletStore backed by a plain map, keyed by pubkey. */ -function fakeWalletStore( - rows: Record, -): { store: WalletStore; deleted: string[] } { +function fakeWalletStore(rows: Record): { + store: WalletStore; + deleted: string[]; +} { const deleted: string[] = []; const store: WalletStore = { wallet: { @@ -146,7 +147,7 @@ test('unlinkWallet refuses the primary wallet', async () => { assert.equal(deleted.length, 0); }); -test('unlinkWallet deletes a non-primary wallet on the caller\'s own profile', async () => { +test("unlinkWallet deletes a non-primary wallet on the caller's own profile", async () => { const { store, deleted } = fakeWalletStore({ [WALLET]: { profileId: 'p1', isPrimary: true }, GSECOND: { profileId: 'p1', isPrimary: false }, diff --git a/apps/web/lib/server/trpc.test.ts b/apps/web/lib/server/trpc.test.ts index 1f118f2..72fb209 100644 --- a/apps/web/lib/server/trpc.test.ts +++ b/apps/web/lib/server/trpc.test.ts @@ -154,8 +154,8 @@ test('registry.resolve normalises the handle to lowercase', async () => { test('registry.lookup rejects a malformed wallet address', async () => { __resetRateLimit(); - await assert.rejects( - () => caller('10.0.0.13').registry.lookup({ wallet: 'not-a-valid-address' }), + await assert.rejects(() => + caller('10.0.0.13').registry.lookup({ wallet: 'not-a-valid-address' }), ); }); @@ -217,10 +217,7 @@ test('a cross-origin mutation is FORBIDDEN', async () => { host: 'signet.dev', origin: 'https://evil.example', }); - assert.equal( - await codeOf(() => c.account.update({ displayName: 'x', bio: null })), - 'FORBIDDEN', - ); + assert.equal(await codeOf(() => c.account.update({ displayName: 'x', bio: null })), 'FORBIDDEN'); }); test('a cross-origin unlinkWallet mutation is FORBIDDEN', async () => {