Skip to content

Commit 5e7029e

Browse files
committed
feat: add a message signing and verification page to the explorer
1 parent 253496f commit 5e7029e

10 files changed

+11895
-3689
lines changed

app/components/Navbar.tsx

+14-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function Navbar() {
1515
const homePath = useClusterPath({ pathname: '/' });
1616
const supplyPath = useClusterPath({ pathname: '/supply' });
1717
const inspectorPath = useClusterPath({ pathname: '/tx/inspector' });
18+
const messagePath = useClusterPath({ pathname: '/message' });
1819
const selectedLayoutSegment = useSelectedLayoutSegment();
1920
const selectedLayoutSegments = useSelectedLayoutSegments();
2021
return (
@@ -48,16 +49,26 @@ export function Navbar() {
4849
</li>
4950
<li className="nav-item">
5051
<Link
51-
className={`nav-link${
52-
selectedLayoutSegments[0] === 'tx' && selectedLayoutSegments[1] === '(inspector)'
52+
className={`nav-link${selectedLayoutSegments[0] === 'tx' && selectedLayoutSegments[1] === '(inspector)'
5353
? ' active'
5454
: ''
55-
}`}
55+
}`}
5656
href={inspectorPath}
5757
>
5858
Inspector
5959
</Link>
6060
</li>
61+
<li className="nav-item">
62+
<Link
63+
className={`nav-link${selectedLayoutSegments[0] === 'tx' && selectedLayoutSegments[1] === '(message)'
64+
? ' active'
65+
: ''
66+
}`}
67+
href={messagePath}
68+
>
69+
Message
70+
</Link>
71+
</li>
6172
</ul>
6273
</div>
6374

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
2+
import {
3+
ConnectionProvider,
4+
WalletProvider,
5+
} from "@solana/wallet-adapter-react";
6+
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
7+
import { UnsafeBurnerWalletAdapter } from '@solana/wallet-adapter-wallets';
8+
import { clusterApiUrl } from "@solana/web3.js";
9+
import React, { FC, ReactNode, useMemo } from "react";
10+
11+
export const SignerWalletContext: FC<{ children: ReactNode }> = ({ children }) => {
12+
// Always use devnet, this page never needs to use RPC
13+
const network = WalletAdapterNetwork.Devnet;
14+
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
15+
16+
const wallets = useMemo(
17+
() => [
18+
new UnsafeBurnerWalletAdapter(),
19+
],
20+
// eslint-disable-next-line react-hooks/exhaustive-deps
21+
[network]
22+
);
23+
24+
return (
25+
<ConnectionProvider endpoint={endpoint}>
26+
<WalletProvider wallets={wallets} autoConnect>
27+
<WalletModalProvider>
28+
{children}
29+
</WalletModalProvider>
30+
</WalletProvider>
31+
</ConnectionProvider>
32+
);
33+
};
+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { ed25519 } from "@noble/curves/ed25519";
2+
import { PublicKey } from "@solana/web3.js";
3+
import bs58 from 'bs58';
4+
import dynamic from 'next/dynamic';
5+
import { SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
6+
7+
import { MeteredMessageBox } from "./MeteredMessageBox";
8+
import { SigningContext, SignMessageBox } from "./SignMessageButton";
9+
10+
const ConnectButton = dynamic(async () => ((await import('@solana/wallet-adapter-react-ui')).WalletMultiButton), { ssr: false });
11+
12+
export type ReportMessageVerification = (verified: boolean, show?: boolean, message?: string) => void;
13+
14+
const MAX_MSG_LENGTH = 1500;
15+
16+
function getPluralizedWord(count: number): string {
17+
return count === 1 ? "character" : "characters";
18+
}
19+
20+
function sanitizeInput(input: string) {
21+
input = input.replace(/<script.*?>.*?<\/script>/gi, '');
22+
if (input.length > MAX_MSG_LENGTH) {
23+
console.log("Message length limit reached. Truncating...");
24+
input = input.substring(0, MAX_MSG_LENGTH);
25+
}
26+
return input;
27+
}
28+
29+
export const MessageForm = (props: { reportVerification: ReportMessageVerification }) => {
30+
const [address, setAddress] = useState("");
31+
const [message, setMessage] = useState("");
32+
const [signature, setSignature] = useState("");
33+
const [addressError, setAddressError] = useState(false);
34+
const [verified, setVerifiedInternal] = useState(false);
35+
36+
const setVerified = useCallback((verified: boolean, show = false, message = "") => {
37+
setVerifiedInternal(verified);
38+
props.reportVerification(verified, show, message);
39+
}, [props]);
40+
41+
const handleAddressChange = useCallback((event: { target: { value: SetStateAction<string>; }; }) => {
42+
setVerified(false);
43+
const update = event.target.value;
44+
setAddress(update);
45+
46+
try {
47+
let isError = false;
48+
if (update.length > 0 && !PublicKey.isOnCurve(update)) {
49+
isError = true;
50+
}
51+
setAddressError(isError);
52+
} catch (error: unknown) {
53+
if (error instanceof Error) {
54+
console.error(error.message);
55+
}
56+
setAddressError(true);
57+
}
58+
// eslint-disable-next-line react-hooks/exhaustive-deps
59+
}, []);
60+
61+
const handleSignatureChange = useCallback((event: { target: { value: SetStateAction<string>; }; }) => {
62+
setVerified(false);
63+
setSignature(event.target.value);
64+
// eslint-disable-next-line react-hooks/exhaustive-deps
65+
}, []);
66+
67+
const handleInputChange = useCallback((event: { target: { value: string; }; }) => {
68+
setVerified(false);
69+
setMessage(sanitizeInput(event.target.value));
70+
// eslint-disable-next-line react-hooks/exhaustive-deps
71+
}, []);
72+
73+
const handleVerifyClick = useCallback(() => {
74+
try {
75+
const verified = ed25519.verify(bs58.decode(signature), new TextEncoder().encode(message), bs58.decode(address));
76+
if (!verified) throw new Error("Message verification failed!");
77+
setVerified(true)
78+
} catch (error) {
79+
console.error("Message verification failed!");
80+
setVerified(false, true);
81+
}
82+
}, [setVerified, address, message, signature]);
83+
84+
useEffect(() => {
85+
const urlParams = new URLSearchParams(window.location.search);
86+
const urlAddress = urlParams.get('address');
87+
const urlMessage = urlParams.get('message');
88+
const urlSignature = urlParams.get('signature');
89+
90+
if (urlAddress && urlMessage && urlSignature) {
91+
handleAddressChange({ target: { value: urlAddress } });
92+
handleInputChange({ target: { value: urlMessage } });
93+
handleSignatureChange({ target: { value: urlSignature } });
94+
}
95+
}, [handleAddressChange, handleInputChange, handleSignatureChange]);
96+
97+
const signingContext = useMemo(() => {
98+
return {
99+
address,
100+
input: message,
101+
setAddress: handleAddressChange,
102+
setInput: handleInputChange,
103+
setSignature: handleSignatureChange,
104+
setVerified,
105+
signature,
106+
} as SigningContext;
107+
}, [message, address, signature, handleAddressChange, handleSignatureChange, handleInputChange, setVerified]);
108+
109+
function writeToClipboard() {
110+
const encodedAddress = encodeURIComponent(address);
111+
const encodedMessage = encodeURIComponent(message);
112+
const encodedSignature = encodeURIComponent(signature);
113+
const newUrl = `${window.location.origin}${window.location.pathname}?address=${encodedAddress}&message=${encodedMessage}&signature=${encodedSignature}`;
114+
navigator.clipboard.writeText(newUrl).catch(err => {
115+
console.error("Failed to copy to clipboard: ", err);
116+
});
117+
}
118+
119+
const placeholder_message = 'Type a message here...';
120+
const placeholder_address = 'Enter an address whose signature you want to verify...';
121+
const placeholder_signature = 'Paste a signature...';
122+
const verifyButtonDisabled = !address || !message || !signature;
123+
124+
return (
125+
<div className="card" >
126+
<div className="card-header" style={{ padding: '2.5rem 1.5rem' }}>
127+
<div className="row align-items-center d-flex justify-content-between">
128+
<div className="col">
129+
<h2 className="card-header-title">Message Signer</h2>
130+
</div>
131+
<div className="col-auto">
132+
<ConnectButton style={{
133+
borderRadius: '0.5rem',
134+
}} />
135+
</div>
136+
</div>
137+
</div>
138+
<div className="card-header">
139+
<h3 className="card-header-title">Address</h3>
140+
</div>
141+
<div className="card-body">
142+
<textarea
143+
rows={2}
144+
onChange={handleAddressChange}
145+
value={address}
146+
className="form-control form-control-auto"
147+
placeholder={placeholder_address}
148+
/>
149+
{addressError && (
150+
<div className="text-warning small mt-2">
151+
<i className="fe fe-alert-circle"></i> Invalid address.
152+
</div>
153+
)}
154+
</div>
155+
<div className="card-header">
156+
<h3 className="card-header-title">Message</h3>
157+
</div>
158+
<div className="card-body">
159+
<MeteredMessageBox
160+
value={message}
161+
onChange={handleInputChange}
162+
placeholder={placeholder_message}
163+
word={getPluralizedWord(MAX_MSG_LENGTH - message.length)}
164+
limit={MAX_MSG_LENGTH}
165+
count={message.length}
166+
charactersremaining={MAX_MSG_LENGTH - message.length} />
167+
</div>
168+
169+
<div className="card-header">
170+
<h3 className="card-header-title">Signature</h3>
171+
</div>
172+
<div className="card-body">
173+
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflowY: 'auto' }}>
174+
<textarea
175+
rows={2}
176+
onChange={handleSignatureChange}
177+
value={signature}
178+
className="form-control form-control-auto"
179+
placeholder={placeholder_signature}
180+
/>
181+
</div>
182+
</div>
183+
184+
<div className="card-footer d-flex justify-content-end">
185+
<div className="me-2" data-bs-toggle="tooltip" data-bs-placement="top" title={!verified ? "Verify first to enable this action" : ""}>
186+
<button
187+
className="btn btn-primary"
188+
onClick={writeToClipboard}
189+
disabled={!verified}
190+
>
191+
Copy URL
192+
</button>
193+
</div>
194+
<div className="me-2" data-bs-toggle="tooltip" data-bs-placement="top" title={verifyButtonDisabled ? "Complete the form to enable this action" : ""}>
195+
<button
196+
className="btn btn-primary"
197+
onClick={handleVerifyClick}
198+
disabled={verifyButtonDisabled}
199+
>
200+
Verify
201+
</button>
202+
</div>
203+
<SignMessageBox className="btn btn-primary me-2" signingcontext={signingContext} />
204+
</div>
205+
</div >
206+
);
207+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use client';
2+
3+
import '@solana/wallet-adapter-react-ui/styles.css'; // Add this line to import default styles
4+
5+
import { useEffect,useState } from 'react';
6+
7+
import { SignerWalletContext } from './MessageContext';
8+
import { MessageForm } from './MessageForm';
9+
10+
export default function MessageSignerPage() {
11+
const [verified, setVerifiedInternal] = useState(false);
12+
const [openVerifiedSnackbar, showVerifiedSnackBar] = useState(false);
13+
const [verificationMessage, setVerificationMessage] = useState("");
14+
15+
function setVerified(verified: boolean, showSnackBar = false, message = "") {
16+
if (verified || showSnackBar) {
17+
showVerifiedSnackBar(true);
18+
} else {
19+
showVerifiedSnackBar(false);
20+
}
21+
setVerificationMessage(message);
22+
setVerifiedInternal(verified);
23+
}
24+
25+
// Auto-dismiss the snackbar after 5 seconds
26+
useEffect(() => {
27+
if (openVerifiedSnackbar) {
28+
const timer = setTimeout(() => {
29+
showVerifiedSnackBar(false);
30+
}, 3000);
31+
32+
return () => clearTimeout(timer);
33+
}
34+
}, [openVerifiedSnackbar]);
35+
36+
const message = verified
37+
? `Message Verified${verificationMessage ? ": " + verificationMessage : ""}`
38+
: `Message Verification Failed${verificationMessage ? ": " + verificationMessage : ""}`;
39+
40+
return (
41+
<SignerWalletContext>
42+
<div style={{
43+
display: 'flex',
44+
flexDirection: 'column',
45+
minHeight: 'auto',
46+
}}>
47+
<div style={{
48+
flex: 1,
49+
overflow: 'auto',
50+
}}>
51+
<MessageForm reportVerification={setVerified} />
52+
</div>
53+
</div>
54+
{openVerifiedSnackbar && (
55+
<div
56+
className={`alert alert-${verified ? 'success' : 'danger'} alert-dismissible fade show mt-3`}
57+
role="alert"
58+
style={{
59+
alignItems: 'center',
60+
borderRadius: '0.5rem',
61+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
62+
display: 'flex',
63+
justifyContent: 'space-between',
64+
margin: '1rem auto',
65+
maxWidth: '600px',
66+
padding: '1rem 1.5rem'
67+
}}
68+
onClick={() => showVerifiedSnackBar(false)}
69+
>
70+
{message}
71+
</div>
72+
)}
73+
</SignerWalletContext>
74+
);
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
3+
export type MsgCounterProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
4+
word: string;
5+
limit: number;
6+
count: number;
7+
charactersremaining: number;
8+
};
9+
10+
export const MeteredMessageBox = (props: MsgCounterProps) => {
11+
return (
12+
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflowY: 'auto' }}>
13+
<textarea
14+
{...props}
15+
className={`form-control ${props.className || ''}`}
16+
rows={3}
17+
maxLength={props.limit}
18+
/>
19+
<div className="d-flex justify-content-between mt-1" style={{ fontSize: '0.875em' }}>
20+
<span>
21+
{props.charactersremaining} {props.word} remaining
22+
</span>
23+
<span>
24+
{props.count}/{props.limit}
25+
</span>
26+
</div>
27+
</div>
28+
);
29+
};

0 commit comments

Comments
 (0)