Skip to content

Commit 9db6613

Browse files
✨ (test-dapp): Add Solana Wallet Standard test page
Add a /solana route to the test dApp that exercises the Solana Wallet Standard via @solana/wallet-adapter-react, with cluster switching, wallet selection, connection status, signMessage, signTransaction and sendTransaction flows. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4991780 commit 9db6613

17 files changed

Lines changed: 2807 additions & 199 deletions

apps/test-dapp/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
"@ledgerhq/lumen-design-core": "^0.1.1",
77
"@ledgerhq/lumen-ui-react": "^0.1.2",
88
"@radix-ui/react-slot": "^1.0.2",
9+
"@solana/wallet-adapter-base": "catalog:",
10+
"@solana/wallet-adapter-react": "catalog:",
11+
"@solana/web3.js": "catalog:",
12+
"bs58": "catalog:",
913
"class-variance-authority": "catalog:",
1014
"clsx": "^2.1.0",
1115
"ethers": "catalog:",

apps/test-dapp/src/app/layout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ThemeToggle } from "../components";
1+
import { HeaderNav, ThemeToggle } from "../components";
22
import { Providers } from "../components/Providers";
33

44
import "./global.css";
@@ -7,7 +7,7 @@ import "@ledgerhq/ledger-wallet-provider/styles.css";
77

88
export const metadata = {
99
title: "Ledger Button Test dApp",
10-
description: "Test EIP-1193 / EIP-6963 provider integration",
10+
description: "Test EIP-1193 / EIP-6963 and Solana Wallet Standard integrations",
1111
};
1212

1313
export default function RootLayout({
@@ -21,9 +21,12 @@ export default function RootLayout({
2121
<Providers>
2222
<div className="flex flex-col min-h-screen">
2323
<header className="flex items-center justify-between px-24 py-12 bg-muted border-b border-muted shrink-0">
24-
<p className="body-2-semi-bold text-base">
25-
Ledger Button · Test dApp
26-
</p>
24+
<div className="flex items-center gap-24">
25+
<p className="body-2-semi-bold text-base">
26+
Ledger Button · Test dApp
27+
</p>
28+
<HeaderNav />
29+
</div>
2730
<div className="flex items-center gap-12">
2831
<ThemeToggle />
2932
<div id="floating-button-container"></div>
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+
import { ChevronDown as ChevronDownIcon } from "@ledgerhq/lumen-ui-react/symbols";
5+
import {
6+
useConnection,
7+
useWallet,
8+
} from "@solana/wallet-adapter-react";
9+
import {
10+
PublicKey,
11+
SystemProgram,
12+
Transaction,
13+
} from "@solana/web3.js";
14+
import bs58 from "bs58";
15+
import dynamic from "next/dynamic";
16+
17+
import { type ActivityEntry, ActivityLog } from "../../components";
18+
import {
19+
DEFAULT_SOLANA_CLUSTER,
20+
SolanaActionsBlock,
21+
type SolanaCluster,
22+
SolanaConnectionStatus,
23+
SolanaSettingsBlock,
24+
WalletSelectionBlock,
25+
} from "../../components/solana";
26+
import { type SolanaTransferValues } from "../../components/solana/modals";
27+
28+
const SolanaProviders = dynamic(
29+
() => import("../../components/solana/SolanaProviders"),
30+
{ ssr: false },
31+
);
32+
33+
let activityCounter = 0;
34+
function nextActivityId(): string {
35+
activityCounter += 1;
36+
return `${Date.now()}-${activityCounter}`;
37+
}
38+
39+
export default function SolanaPage() {
40+
const [cluster, setCluster] = useState<SolanaCluster>(DEFAULT_SOLANA_CLUSTER);
41+
42+
return (
43+
<SolanaProviders cluster={cluster}>
44+
<SolanaPageContent cluster={cluster} onClusterChange={setCluster} />
45+
</SolanaProviders>
46+
);
47+
}
48+
49+
interface SolanaPageContentProps {
50+
cluster: SolanaCluster;
51+
onClusterChange: (cluster: SolanaCluster) => void;
52+
}
53+
54+
function SolanaPageContent({
55+
cluster,
56+
onClusterChange,
57+
}: SolanaPageContentProps) {
58+
const { connection } = useConnection();
59+
const {
60+
publicKey,
61+
connected,
62+
signMessage,
63+
signTransaction,
64+
sendTransaction,
65+
} = useWallet();
66+
67+
const [activity, setActivity] = useState<ActivityEntry[]>([]);
68+
const [result, setResult] = useState<string | null>(null);
69+
const [error, setError] = useState<string | null>(null);
70+
71+
const prevResultRef = useRef<string | null>(null);
72+
const prevErrorRef = useRef<string | null>(null);
73+
74+
useEffect(() => {
75+
if (result && result !== prevResultRef.current) {
76+
setActivity((prev) => [
77+
...prev,
78+
{
79+
id: nextActivityId(),
80+
kind: "result",
81+
label: "Result",
82+
timestamp: new Date(),
83+
data: result,
84+
},
85+
]);
86+
}
87+
prevResultRef.current = result;
88+
}, [result]);
89+
90+
useEffect(() => {
91+
if (error && error !== prevErrorRef.current) {
92+
setActivity((prev) => [
93+
...prev,
94+
{
95+
id: nextActivityId(),
96+
kind: "error",
97+
label: "Error",
98+
timestamp: new Date(),
99+
data: error,
100+
},
101+
]);
102+
}
103+
prevErrorRef.current = error;
104+
}, [error]);
105+
106+
const addInfo = useCallback((label: string, data?: unknown) => {
107+
setActivity((prev) => [
108+
...prev,
109+
{
110+
id: nextActivityId(),
111+
kind: "info",
112+
label,
113+
timestamp: new Date(),
114+
data,
115+
},
116+
]);
117+
}, []);
118+
119+
const addError = useCallback((message: string) => {
120+
setError(message);
121+
}, []);
122+
123+
const clearActivity = useCallback(() => {
124+
setActivity([]);
125+
}, []);
126+
127+
const clearResult = useCallback(() => {
128+
setResult(null);
129+
setError(null);
130+
}, []);
131+
132+
const buildTransferTransaction = useCallback(
133+
async ({ recipient, lamports }: SolanaTransferValues) => {
134+
if (!publicKey) throw new Error("Wallet not connected");
135+
const toPubkey = new PublicKey(recipient);
136+
const { blockhash, lastValidBlockHeight } =
137+
await connection.getLatestBlockhash();
138+
const tx = new Transaction({
139+
feePayer: publicKey,
140+
blockhash,
141+
lastValidBlockHeight,
142+
}).add(
143+
SystemProgram.transfer({
144+
fromPubkey: publicKey,
145+
toPubkey,
146+
lamports,
147+
}),
148+
);
149+
return tx;
150+
},
151+
[connection, publicKey],
152+
);
153+
154+
const handleSignMessage = useCallback(
155+
async (message: string) => {
156+
if (!signMessage) {
157+
setError("Selected wallet does not support signMessage");
158+
return;
159+
}
160+
setResult(null);
161+
setError(null);
162+
try {
163+
addInfo("signMessage", message);
164+
const bytes = new TextEncoder().encode(message);
165+
const signature = await signMessage(bytes);
166+
setResult(bs58.encode(signature));
167+
} catch (err) {
168+
setError((err as Error)?.message ?? String(err));
169+
}
170+
},
171+
[signMessage, addInfo],
172+
);
173+
174+
const handleSignTransaction = useCallback(
175+
async (values: SolanaTransferValues) => {
176+
if (!signTransaction) {
177+
setError("Selected wallet does not support signTransaction");
178+
return;
179+
}
180+
setResult(null);
181+
setError(null);
182+
try {
183+
addInfo("signTransaction (SystemProgram.transfer)", values);
184+
const tx = await buildTransferTransaction(values);
185+
const signed = await signTransaction(tx);
186+
setResult(signed.serialize().toString("base64"));
187+
} catch (err) {
188+
setError((err as Error)?.message ?? String(err));
189+
}
190+
},
191+
[signTransaction, addInfo, buildTransferTransaction],
192+
);
193+
194+
const handleSendTransaction = useCallback(
195+
async (values: SolanaTransferValues) => {
196+
setResult(null);
197+
setError(null);
198+
try {
199+
addInfo("sendTransaction (SystemProgram.transfer)", values);
200+
const tx = await buildTransferTransaction(values);
201+
const signature = await sendTransaction(tx, connection);
202+
addInfo(`Sent — signature ${signature.slice(0, 12)}…`);
203+
const { blockhash, lastValidBlockHeight } =
204+
await connection.getLatestBlockhash();
205+
const confirmation = await connection.confirmTransaction({
206+
signature,
207+
blockhash,
208+
lastValidBlockHeight,
209+
});
210+
if (confirmation.value.err) {
211+
throw new Error(JSON.stringify(confirmation.value.err));
212+
}
213+
setResult(signature);
214+
} catch (err) {
215+
setError((err as Error)?.message ?? String(err));
216+
}
217+
},
218+
[sendTransaction, connection, addInfo, buildTransferTransaction],
219+
);
220+
221+
const isConnected = connected && publicKey !== null;
222+
223+
const headerSubtitle = useMemo(
224+
() =>
225+
`Wallet Standard discovery on Solana ${cluster.replace("-beta", " beta")}`,
226+
[cluster],
227+
);
228+
229+
return (
230+
<div className="bg-canvas min-h-full p-24">
231+
<div className="mx-auto flex max-w-[1440px] gap-24">
232+
<div className="max-w-[720px] min-w-0 flex-1">
233+
<header className="mb-24">
234+
<h1 className="heading-3 mb-6 text-base">
235+
Ledger Button Test dApp · Solana
236+
</h1>
237+
<p className="body-2 text-muted">{headerSubtitle}</p>
238+
</header>
239+
240+
<div className="flex flex-col gap-20">
241+
<SolanaSettingsBlock
242+
cluster={cluster}
243+
onClusterChange={onClusterChange}
244+
/>
245+
246+
<WalletSelectionBlock onLog={addInfo} onError={addError} />
247+
248+
<SolanaActionsBlock
249+
isConnected={isConnected}
250+
canSignMessage={Boolean(signMessage)}
251+
canSignTransaction={Boolean(signTransaction)}
252+
onSignMessage={handleSignMessage}
253+
onSignTransaction={handleSignTransaction}
254+
onSendTransaction={handleSendTransaction}
255+
result={result}
256+
error={error}
257+
onClearResult={clearResult}
258+
/>
259+
</div>
260+
</div>
261+
262+
<aside className="hidden w-[400px] shrink-0 lg:block">
263+
<div className="sticky top-24 flex max-h-[calc(100vh-48px)] flex-col gap-20">
264+
<div className="shrink-0">
265+
<SolanaConnectionStatus cluster={cluster} />
266+
</div>
267+
<div className="flex min-h-0 flex-1 flex-col">
268+
<ActivityLog entries={activity} onClear={clearActivity} />
269+
</div>
270+
</div>
271+
</aside>
272+
</div>
273+
274+
<div className="mx-auto mt-16 max-w-[680px] space-y-8 lg:hidden">
275+
<details className="group">
276+
<summary className="border-muted bg-muted flex cursor-pointer items-center justify-between rounded-lg border px-20 py-14 select-none">
277+
<span className="body-2-semi-bold text-base">
278+
Activity Log
279+
{activity.length > 0 && (
280+
<span className="text-muted ml-8">({activity.length})</span>
281+
)}
282+
</span>
283+
<span className="text-muted transition-transform group-open:rotate-180">
284+
<ChevronDownIcon size={16} />
285+
</span>
286+
</summary>
287+
<div className="mt-8 h-[400px]">
288+
<ActivityLog entries={activity} onClear={clearActivity} />
289+
</div>
290+
</details>
291+
</div>
292+
</div>
293+
);
294+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname } from "next/navigation";
5+
6+
import { cn } from "../lib/utils";
7+
8+
interface NavLink {
9+
href: string;
10+
label: string;
11+
match: (pathname: string) => boolean;
12+
}
13+
14+
const LINKS: NavLink[] = [
15+
{
16+
href: "/",
17+
label: "EVM",
18+
match: (pathname) => pathname === "/",
19+
},
20+
{
21+
href: "/solana",
22+
label: "Solana",
23+
match: (pathname) => pathname.startsWith("/solana"),
24+
},
25+
];
26+
27+
export function HeaderNav() {
28+
const pathname = usePathname() ?? "/";
29+
30+
return (
31+
<nav className="flex items-center gap-4">
32+
{LINKS.map((link) => {
33+
const isActive = link.match(pathname);
34+
return (
35+
<Link
36+
key={link.href}
37+
href={link.href}
38+
className={cn(
39+
"px-12 py-6 rounded-lg body-2-semi-bold transition-colors",
40+
isActive
41+
? "bg-muted-transparent text-base border border-active"
42+
: "text-muted hover:text-base hover:bg-muted-transparent border border-transparent",
43+
)}
44+
>
45+
{link.label}
46+
</Link>
47+
);
48+
})}
49+
</nav>
50+
);
51+
}

apps/test-dapp/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { type ActivityEntry, ActivityLog } from "./ActivityLog";
22
export { ConnectionStatus } from "./ConnectionStatus";
33
export { type EIPEvent } from "./EventLogBlock";
44
export { EventSimulatorBlock } from "./EventSimulatorBlock";
5+
export { HeaderNav } from "./HeaderNav";
56
export { Providers } from "./Providers";
67
export { ProviderSelectionBlock } from "./ProviderSelectionBlock";
78
export { SettingsBlock } from "./SettingsBlock";

0 commit comments

Comments
 (0)