So, what we’ve got here is a Magic 8 ball app built with React, where the twist is that instead of using basic RNG to pick one of the responses, we’re using a wallet signature from the Sui blockchain to generate that randomness.
It’s a “fun” demo that gives you a chance to understand wallet message signing, verify user intent, and play with personalized output — all without needing to touch on transactions or gas fees.
The source code can be found here — https://github.com/delaklo/magic-8ball-on-sui. And, of course, you can test this app deployed on github Pages — https://delaklo.github.io/magic-8ball-on-sui/ . Make sure you switch to devnet mode in your wallet. Let’s focus on the main file — SuiMagic8Ball.tsx
.
Disclaimer: I’m not that good a typescript developer. The purpose of this material is to explain how the useSignPersonalMessage hook works and how to build an application around it. So the final code Needs improvements. If you have free time, you are welcome!
So, let’s break down core file SuiMagic8Ball.tsx! When you open SuiMagic8Ball.tsx
, the very first lines pull in React and a couple of hooks from @mysten/dapp-kit
. You’ll see:
import { useState, useCallback } from 'react';
import { ConnectButton, useCurrentAccount, useSignPersonalMessage } from '@mysten/dapp-kit';
The useCurrentAccount
hook quietly tracks which wallet (if any) is connected, while useSignPersonalMessage
gives us a mutaate function under the hood that will pop up the wallet’s “Sign this message?” dialog.
A little further down you find the array named responses with twenty classic 8-ball replies:
const responses = [ "It is certain", "It is decidedly so", // etc];
The next helper generateRandomFromSignature
is where we turn a signature into a number:
const generateRandomFromSignature = (signature: Uint8Array): number => {
const hexString = Array.from(signature, (byte: number) =>
('0' + (byte & 0xFF).toString(16)).slice(-2)
).join('');
const hexSegment = hexString.substring(0, 8);
const randomSeed = parseInt(hexSegment, 16)
|| Math.floor(Math.random() * 1000000);
return randomSeed % responses.length;
};
What happened here is as simple as that (no, it’s sarcasm). It takes the raw Uint8Array you get back from the wallet, converts each byte to a two-character hex pair, glues them into one long string, and then slices off the first eigh characters. COnverting that slice to a number (base-16) gives us a seed that we reduce modulo responses.length
. In practice that seed is unpredictable unless you know the private key, so it behaves like a cryptographically secure random number generator.(but mathematically saying it’s not)
Now on to the stateful part. You’ll see a handful of useState
calls:
const [question, setQuestion] = useState<string>('');
const [answer, setAnswer] = useState<string>('Ask and sign to reveal');
const [isShaking, setIsShaking] = useState(false);
const [isAsking, setIsAsking] = useState(false);
const [status, setStatus] = useState<{ message: string; type: "success" | "error" | "info"; isVisible: boolean }>({
message: '',
type: 'info',
isVisible: false
});
const [signatureDetails, setSignatureDetails] = useState({
message: '',
signature: '',
responseIndex: 0,
isVisible: false
});
We track the user’s input, the current ball answer, flags for disabling inputs and playing animations, plus two little objects for status banners and signature info. Right below that, useCurrentAccount() gives you currentAccount, or undefined if nothing is connected, and useSignPersonalMessage() hands you a mutate function, which we rename to signPersonalMessage for clarity.
const currentAccount = useCurrentAccount();
const { mutate: signPersonalMessage } = useSignPersonalMessage();
The showStatus helper is wrapped in useCallback, so you never recreate it by accident on every render:
const showStatus = useCallback((message: string, type: "success" | "error" | "info") => {
setStatus({ message, type, isVisible: true });
if (type === 'success' || type === 'info') {
setTimeout(() => setStatus(prev => ({ ...prev, isVisible: false })), 5000);
}
}, []);
That little timeout makes info and success messages auto-hide after five seconds.
And… The heart of the component is the askMagic8Ball
function:
const askMagic8Ball = useCallback(async () => {
if (!question.trim())
return showStatus("Please enter a question first!", "error");
if (!currentAccount)
return showStatus("please connect your wallet first!", "error");
try {
setIsAsking(true);
setIsShaking(true);
setAnswer("...");
showStatus("Please sign the message in your wallet...", "info");
const timestamp = Date.now();
const message = `Magic 8 Ball question: "${question}" - ${timestamp}`;
const messageBytes = new TextEncoder().encode(message);
signPersonalMessage(
{ message: messageBytes },
{
onSuccess: (result) => {
try {
const signatureBytes = new Uint8Array(
result.signature.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []
);
const responseIndex = generateRandomFromSignature(
signatureBytes,
);
const selectedAnswer = responses[responseIndex];
const signatureHex = result.signature;
setSignatureDetails({
message,
signature: signatureHex,
responseIndex,
isVisible: true,
});
setTimeout(() => {
setIsShaking(false);
setAnswer(selectedAnswer);
showStatus("The Magic 8 Ball has spoken!", "success");
setIsAsking(false);
}, 1000);
} catch (err) {
console.error(err);
setIsShaking(false);
setAnswer("Error occrred");
showStatus("Error processing signature", "error");
setIsAsking(false);
}
},
onError: (err) => {
console.error(err);
setIsShaking(false);
setAnswer("Signing failed");
showStatus(`Signing failed: ${err.message}`, "error");
setIsAsking(false);
},
},
);
} catch (err) {
console.error(err);
setIsShaking(false);
setAnswer("Error occurred");
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
showStatus(`Error: ${errorMessage}`, "error");
setIsAsking(false);
}
}, [question, currentAccount, signPersonalMessage, showStatus]);
It seems a little big so let’’s break it down. It starts by refusing to continue if there’s no question or no wallet, then immediately shows a loading state by setting isAsking
and isShaking
to true and replacing the ball text with …. It builds the string to sign by concatenating the question and Date.now(), then encodes it to a byte array.
That array is exactly what the wallet expects, so passing it into signPersonalMessage
triggers the wallet popup. On success we pull back the byte-array signature, run our generator helper, convert it to hex for display, and finally update the UI after the one-second shake finishes. If an error ever bubbles up, we gracfully stop the animation, show an error message in the ball, and inform the user.
This is Howie Dewitt. I mean, this is how we do it. You can explore other parts in the repo. Feel free to explore the rest of the code in the repo, remix it, or use it as a springboard for your own experiments.
Until next time! May your signatures be valid and your answers always be mystical.
Best regards,
Stransey.sui
If you’re new to Sui or curious about building similar apps, here are some official developer resources to get you started:
Sui Developer Docs — React App Integration
Learn how to scaffold a React app with the Sui dApp Kit, connect wallets, and use signing/messaging tools.
delaklo