Skip to content

Commit

Permalink
feat: add a message signing and verification page to the explorer
Browse files Browse the repository at this point in the history
  • Loading branch information
sagar-a16z committed Oct 8, 2024
1 parent 253496f commit 34c1315
Show file tree
Hide file tree
Showing 10 changed files with 11,895 additions and 3,689 deletions.
17 changes: 14 additions & 3 deletions app/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function Navbar() {
const homePath = useClusterPath({ pathname: '/' });
const supplyPath = useClusterPath({ pathname: '/supply' });
const inspectorPath = useClusterPath({ pathname: '/tx/inspector' });
const messagePath = useClusterPath({ pathname: '/message' });
const selectedLayoutSegment = useSelectedLayoutSegment();
const selectedLayoutSegments = useSelectedLayoutSegments();
return (
Expand Down Expand Up @@ -48,16 +49,26 @@ export function Navbar() {
</li>
<li className="nav-item">
<Link
className={`nav-link${
selectedLayoutSegments[0] === 'tx' && selectedLayoutSegments[1] === '(inspector)'
className={`nav-link${selectedLayoutSegments[0] === 'tx' && selectedLayoutSegments[1] === '(inspector)'
? ' active'
: ''
}`}
}`}
href={inspectorPath}
>
Inspector
</Link>
</li>
<li className="nav-item">
<Link
className={`nav-link${selectedLayoutSegments[0] === 'tx' && selectedLayoutSegments[1] === '(message)'
? ' active'
: ''
}`}
href={messagePath}
>
Message
</Link>
</li>
</ul>
</div>

Expand Down
33 changes: 33 additions & 0 deletions app/components/message/MessageContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
import {
ConnectionProvider,
WalletProvider,
} from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { UnsafeBurnerWalletAdapter } from '@solana/wallet-adapter-wallets';
import { clusterApiUrl } from "@solana/web3.js";
import React, { FC, ReactNode, useMemo } from "react";

export const SignerWalletContext: FC<{ children: ReactNode }> = ({ children }) => {
// Always use devnet, this page never needs to use RPC
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);

const wallets = useMemo(
() => [
new UnsafeBurnerWalletAdapter(),
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[network]
);

return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
};
207 changes: 207 additions & 0 deletions app/components/message/MessageForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { ed25519 } from "@noble/curves/ed25519";
import { PublicKey } from "@solana/web3.js";
import bs58 from 'bs58';
import dynamic from 'next/dynamic';
import { SetStateAction, useCallback, useEffect, useMemo, useState } from "react";

import { MeteredMessageBox } from "./MeteredMessageBox";
import { SigningContext, SignMessageBox } from "./SignMessageButton";

const ConnectButton = dynamic(async () => ((await import('@solana/wallet-adapter-react-ui')).WalletMultiButton), { ssr: false });

export type ReportMessageVerification = (verified: boolean, show?: boolean, message?: string) => void;

const MAX_MSG_LENGTH = 1500;

function getPluralizedWord(count: number): string {
return count === 1 ? "character" : "characters";
}

function sanitizeInput(input: string) {
input = input.replace(/<script.*?>.*?<\/script>/gi, '');
if (input.length > MAX_MSG_LENGTH) {
console.log("Message length limit reached. Truncating...");
input = input.substring(0, MAX_MSG_LENGTH);
}
return input;
}

export const MessageForm = (props: { reportVerification: ReportMessageVerification }) => {
const [address, setAddress] = useState("");
const [message, setMessage] = useState("");
const [signature, setSignature] = useState("");
const [addressError, setAddressError] = useState(false);
const [verified, setVerifiedInternal] = useState(false);

const setVerified = useCallback((verified: boolean, show = false, message = "") => {
setVerifiedInternal(verified);
props.reportVerification(verified, show, message);
}, [props]);

const handleAddressChange = useCallback((event: { target: { value: SetStateAction<string>; }; }) => {
setVerified(false);
const update = event.target.value;
setAddress(update);

try {
let isError = false;
if (update.length > 0 && !PublicKey.isOnCurve(update)) {
isError = true;
}
setAddressError(isError);
} catch (error: unknown) {
if (error instanceof Error) {
console.error(error.message);
}
setAddressError(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleSignatureChange = useCallback((event: { target: { value: SetStateAction<string>; }; }) => {
setVerified(false);
setSignature(event.target.value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleInputChange = useCallback((event: { target: { value: string; }; }) => {
setVerified(false);
setMessage(sanitizeInput(event.target.value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleVerifyClick = useCallback(() => {
try {
const verified = ed25519.verify(bs58.decode(signature), new TextEncoder().encode(message), bs58.decode(address));
if (!verified) throw new Error("Message verification failed!");
setVerified(true)
} catch (error) {
console.error("Message verification failed!");
setVerified(false, true);
}
}, [setVerified, address, message, signature]);

useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const urlAddress = urlParams.get('address');
const urlMessage = urlParams.get('message');
const urlSignature = urlParams.get('signature');

if (urlAddress && urlMessage && urlSignature) {
handleAddressChange({ target: { value: urlAddress } });
handleInputChange({ target: { value: urlMessage } });
handleSignatureChange({ target: { value: urlSignature } });
}
}, [handleAddressChange, handleInputChange, handleSignatureChange]);

const signingContext = useMemo(() => {
return {
address,
input: message,
setAddress: handleAddressChange,
setInput: handleInputChange,
setSignature: handleSignatureChange,
setVerified,
signature,
} as SigningContext;
}, [message, address, signature, handleAddressChange, handleSignatureChange, handleInputChange, setVerified]);

function writeToClipboard() {
const encodedAddress = encodeURIComponent(address);
const encodedMessage = encodeURIComponent(message);
const encodedSignature = encodeURIComponent(signature);
const newUrl = `${window.location.origin}${window.location.pathname}?address=${encodedAddress}&message=${encodedMessage}&signature=${encodedSignature}`;
navigator.clipboard.writeText(newUrl).catch(err => {
console.error("Failed to copy to clipboard: ", err);
});
}

const placeholder_message = 'Type a message here...';
const placeholder_address = 'Enter an address whose signature you want to verify...';
const placeholder_signature = 'Paste a signature...';
const verifyButtonDisabled = !address || !message || !signature;

return (
<div className="card" >
<div className="card-header" style={{ padding: '2.5rem 1.5rem' }}>
<div className="row align-items-center d-flex justify-content-between">
<div className="col">
<h2 className="card-header-title">Message Signer</h2>
</div>
<div className="col-auto">
<ConnectButton style={{
borderRadius: '0.5rem',
}} />
</div>
</div>
</div>
<div className="card-header">
<h3 className="card-header-title">Address</h3>
</div>
<div className="card-body">
<textarea
rows={2}
onChange={handleAddressChange}
value={address}
className="form-control form-control-auto"
placeholder={placeholder_address}
/>
{addressError && (
<div className="text-warning small mt-2">
<i className="fe fe-alert-circle"></i> Invalid address.
</div>
)}
</div>
<div className="card-header">
<h3 className="card-header-title">Message</h3>
</div>
<div className="card-body">
<MeteredMessageBox
value={message}
onChange={handleInputChange}
placeholder={placeholder_message}
word={getPluralizedWord(MAX_MSG_LENGTH - message.length)}
limit={MAX_MSG_LENGTH}
count={message.length}
charactersremaining={MAX_MSG_LENGTH - message.length} />
</div>

<div className="card-header">
<h3 className="card-header-title">Signature</h3>
</div>
<div className="card-body">
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflowY: 'auto' }}>
<textarea
rows={2}
onChange={handleSignatureChange}
value={signature}
className="form-control form-control-auto"
placeholder={placeholder_signature}
/>
</div>
</div>

<div className="card-footer d-flex justify-content-end">
<div className="me-2" data-bs-toggle="tooltip" data-bs-placement="top" title={!verified ? "Verify first to enable this action" : ""}>
<button
className="btn btn-primary"
onClick={writeToClipboard}
disabled={!verified}
>
Copy URL
</button>
</div>
<div className="me-2" data-bs-toggle="tooltip" data-bs-placement="top" title={verifyButtonDisabled ? "Complete the form to enable this action" : ""}>
<button
className="btn btn-primary"
onClick={handleVerifyClick}
disabled={verifyButtonDisabled}
>
Verify
</button>
</div>
<SignMessageBox className="btn btn-primary me-2" signingcontext={signingContext} />
</div>
</div >
);
};
75 changes: 75 additions & 0 deletions app/components/message/MessageSignerPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import '@solana/wallet-adapter-react-ui/styles.css';

import { useEffect, useState } from 'react';

import { SignerWalletContext } from './MessageContext';
import { MessageForm } from './MessageForm';

export default function MessageSignerPage() {
const [verified, setVerifiedInternal] = useState(false);
const [openVerifiedSnackbar, showVerifiedSnackBar] = useState(false);
const [verificationMessage, setVerificationMessage] = useState("");

function setVerified(verified: boolean, showSnackBar = false, message = "") {
if (verified || showSnackBar) {
showVerifiedSnackBar(true);
} else {
showVerifiedSnackBar(false);
}
setVerificationMessage(message);
setVerifiedInternal(verified);
}

// Auto-dismiss the snackbar after 5 seconds
useEffect(() => {
if (openVerifiedSnackbar) {
const timer = setTimeout(() => {
showVerifiedSnackBar(false);
}, 3000);

return () => clearTimeout(timer);
}
}, [openVerifiedSnackbar]);

const message = verified
? `Message Verified${verificationMessage ? ": " + verificationMessage : ""}`
: `Message Verification Failed${verificationMessage ? ": " + verificationMessage : ""}`;

return (
<SignerWalletContext>
<div style={{
display: 'flex',
flexDirection: 'column',
minHeight: 'auto',
}}>
<div style={{
flex: 1,
overflow: 'auto',
}}>
<MessageForm reportVerification={setVerified} />
</div>
</div>
{openVerifiedSnackbar && (
<div
className={`alert alert-${verified ? 'success' : 'danger'} alert-dismissible fade show mt-3`}
role="alert"
style={{
alignItems: 'center',
borderRadius: '0.5rem',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
display: 'flex',
justifyContent: 'space-between',
margin: '1rem auto',
maxWidth: '600px',
padding: '1rem 1.5rem'
}}
onClick={() => showVerifiedSnackBar(false)}
>
{message}
</div>
)}
</SignerWalletContext>
);
}
29 changes: 29 additions & 0 deletions app/components/message/MeteredMessageBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';

export type MsgCounterProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
word: string;
limit: number;
count: number;
charactersremaining: number;
};

export const MeteredMessageBox = (props: MsgCounterProps) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflowY: 'auto' }}>
<textarea
{...props}
className={`form-control ${props.className || ''}`}
rows={3}
maxLength={props.limit}
/>
<div className="d-flex justify-content-between mt-1" style={{ fontSize: '0.875em' }}>
<span>
{props.charactersremaining} {props.word} remaining
</span>
<span>
{props.count}/{props.limit}
</span>
</div>
</div>
);
};
Loading

0 comments on commit 34c1315

Please sign in to comment.