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
35 changes: 24 additions & 11 deletions apps/web/app/(dashboard)/app/wallets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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;
Expand Down Expand Up @@ -35,7 +36,10 @@ export default async function WalletsPage() {

return (
<section>
<h1 className="text-[40px] font-bold leading-[0.96] tracking-[-0.025em] md:text-[56px]" style={display}>
<h1
className="text-[40px] font-bold leading-[0.96] tracking-[-0.025em] md:text-[56px]"
style={display}
>
Wallets
</h1>

Expand Down Expand Up @@ -64,7 +68,10 @@ export default async function WalletsPage() {
{truncate(w.pubkey)}
</span>
{w.isPrimary && (
<span className="border border-[#1f1d19] px-2 py-0.5 text-[9px] uppercase tracking-[0.18em] text-[#8a8779]" style={mono}>
<span
className="border border-[#1f1d19] px-2 py-0.5 text-[9px] uppercase tracking-[0.18em] text-[#8a8779]"
style={mono}
>
Primary
</span>
)}
Expand All @@ -75,15 +82,21 @@ export default async function WalletsPage() {
{SOURCE_BADGE[w.source].label}
</span>
</div>
<a
href={stellarExpertAccountUrl(w.pubkey)}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] uppercase tracking-[0.2em] text-[#8b1a1a] transition-colors hover:text-[#c2410c]"
style={mono}
>
Explorer ↗
</a>
<div className="flex items-center gap-6">
<a
href={stellarExpertAccountUrl(w.pubkey)}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] uppercase tracking-[0.2em] text-[#8b1a1a] transition-colors hover:text-[#c2410c]"
style={mono}
>
Explorer ↗
</a>
{/* 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 && <UnlinkWalletButton pubkey={w.pubkey} />}
</div>
</div>
))
)}
Expand Down
83 changes: 83 additions & 0 deletions apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx
Original file line number Diff line number Diff line change
@@ -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<State>({ 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 (
<span className="flex items-center gap-3 text-[10px] uppercase tracking-[0.2em]" style={mono}>
<span className="text-[#8a8779]">Unlink this wallet?</span>
<button
type="button"
onClick={confirmUnlink}
className="text-[#8b1a1a] transition-colors hover:text-[#c2410c]"
>
Confirm
</button>
<button
type="button"
onClick={() => setState({ status: 'idle' })}
className="text-[#5e5b51] transition-colors hover:text-[#b8b5a8]"
>
Cancel
</button>
</span>
);
}

if (state.status === 'error') {
return (
<span className="flex items-center gap-3 text-[10px] uppercase tracking-[0.2em]" style={mono}>
<span className="text-[#8b1a1a]">{state.message}</span>
<button
type="button"
onClick={() => setState({ status: 'idle' })}
className="text-[#5e5b51] transition-colors hover:text-[#b8b5a8]"
>
Dismiss
</button>
</span>
);
}

return (
<button
type="button"
onClick={() => setState({ status: 'confirming' })}
disabled={state.status === 'busy'}
className="text-[10px] uppercase tracking-[0.2em] text-[#5e5b51] transition-colors hover:text-[#8b1a1a] disabled:opacity-60"
style={mono}
>
{state.status === 'busy' ? 'Unlinking…' : 'Unlink'}
</button>
);
}
65 changes: 64 additions & 1 deletion apps/web/lib/server/account.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -92,3 +92,66 @@ 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<string, { profileId: string; isPrimary: boolean }>): {
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']);
});
58 changes: 58 additions & 0 deletions apps/web/lib/server/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,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<unknown>;
};
}

/**
* 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<void> {
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 } });
}
54 changes: 51 additions & 3 deletions apps/web/lib/server/trpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -119,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' }),
);
});

Expand Down Expand Up @@ -182,8 +217,21 @@ 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');
});

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.update({ displayName: 'x', bio: null })),
await codeOf(() =>
c.account.unlinkWallet({
wallet: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG',
}),
),
'FORBIDDEN',
);
});
Expand Down
10 changes: 9 additions & 1 deletion apps/web/lib/server/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,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';

/**
Expand Down Expand Up @@ -145,6 +145,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)),
});

/**
Expand Down