-
Notifications
You must be signed in to change notification settings - Fork 304
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add a message signing and verification page to the explorer
- Loading branch information
1 parent
253496f
commit 34c1315
Showing
10 changed files
with
11,895 additions
and
3,689 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 > | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
Oops, something went wrong.