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 (
-