Create the frontend application
Deno is not mandatory as you can still use npm, but we use it on this tutorial. You can install it with this link
-
Create a frontend app on the same project root directory. Here we use
ViteandReactto start a default project;deno run -A npm:create-vite@latestIf you have trouble with Deno, as on some Mac computers, you can create a non-deno project that works in a similar way by running this command:
npm create vite@latest. -
Choose a name for the frontend project (such as
app, which is what the examples later use), select theReactframework, and select theTypescriptlanguage. -
Run the commands as in this example to install the dependencies and start the server:
cd app npm install npm run devNow the Deno server is running a starter frontend application.
-
Stay your frontend
./appproject and import theViemlibrary for blockchain interactions,thirdwebfor the wallet connection andbignumberfor calculations on large numbers:npm i viem thirdweb bignumber.js -
Add the
typechainlibrary to generate your contract structures and Typescript ABI classes from your ABI json file, that is your smart contract descriptor:npm i -D typechain @typechain/ethers-v6 -
Add this line to the
scriptssection of the./app/package.jsonfile in the frontend application:"postinstall": "cp ../ignition/deployments/chain-128123/deployed_addresses.json ./src && typechain --target=ethers-v6 --out-dir=./src/typechain-types --show-stack-traces ../artifacts/contracts/Marketpulse.sol/Marketpulse.json",This script copies the output address of the last deployed contract into your source files and calls
typechainto generate types from the ABI file from the Hardhat folders. -
Run
npm ito call the postinstall script automatically. You should see new files and folders in the./srcfolder of the frontend application. -
Create an utility file to manage Viem errors. Better than the technical defaults and not helpful ones
touch ./app/src/DecodeEvmTransactionLogsArgs.ts -
Put this code in the
./app/src/DecodeEvmTransactionLogsArgs.tsfile:import { Abi, BaseError, ContractFunctionRevertedError, decodeErrorResult, } from "viem"; // Type-Safe Error Handling Interface interface DetailedError { type: "DecodedError" | "RawError" | "UnknownError"; message: string; details?: string; errorData?: any; } // Advanced Error Extraction Function export function extractErrorDetails(error: unknown, abi: Abi): DetailedError { // Type guard for BaseError if (error instanceof BaseError) { // Type guard for ContractFunctionRevertedError if (error.walk() instanceof ContractFunctionRevertedError) { try { // Safe data extraction const revertError = error.walk() as ContractFunctionRevertedError; // Extract error data safely const errorData = (revertError as any).data; // Attempt to decode error if (errorData) { try { // Generic error ABI for decoding const errorAbi = abi; const decodedError = decodeErrorResult({ abi: errorAbi, data: errorData, }); return { type: "DecodedError", message: decodedError.errorName || "Contract function reverted", details: decodedError.args?.toString(), errorData, }; } catch { // Fallback if decoding fails return { type: "RawError", message: "Could not decode error", errorData, }; } } } catch (extractionError) { // Fallback error extraction return { type: "UnknownError", message: error.shortMessage || "Unknown contract error", details: error.message, }; } } // Generic BaseError handling return { type: "RawError", message: error.shortMessage || "Base error occurred", details: error.message, }; } // Fallback for non-BaseError return { type: "UnknownError", message: "message" in (error as any) ? (error as any).message : String(error), details: error instanceof Error ? error.message : undefined, }; } -
Edit
./app/src/main.tsxto add aThirdwebprovider around your application. In the following example, replace line 7<THIRDWEB_CLIENTID>with your ownclientIdconfigured on the Thirdweb dashboard here:import { createRoot } from "react-dom/client"; import { createThirdwebClient } from "thirdweb"; import { ThirdwebProvider } from "thirdweb/react"; import App from "./App.tsx"; import "./index.css"; const client = createThirdwebClient({ clientId: "<THIRDWEB_CLIENTID>", }); createRoot(document.getElementById("root")!).render( <ThirdwebProvider> <App thirdwebClient={client} /> </ThirdwebProvider> );ThirdwebProvider encapsulates your application to inject account context and wrapped Viem functions
-
Edit
App.tsxto have this code:import { Marketpulse, Marketpulse__factory } from "./typechain-types"; import BigNumber from "bignumber.js"; import { useEffect, useState } from "react"; import "./App.css"; import { defineChain, getContract, prepareContractCall, readContract, sendTransaction, ThirdwebClient, waitForReceipt, } from "thirdweb"; import { ConnectButton, useActiveAccount } from "thirdweb/react"; import { createWallet, inAppWallet } from "thirdweb/wallets"; import { parseEther } from "viem"; import { etherlinkTestnet } from "viem/chains"; import { extractErrorDetails } from "./DecodeEvmTransactionLogsArgs"; import CONTRACT_ADDRESS_JSON from "./deployed_addresses.json"; const wallets = [ inAppWallet({ auth: { options: ["google", "email", "passkey", "phone"], }, }), createWallet("io.metamask"), createWallet("com.coinbase.wallet"), createWallet("io.rabby"), createWallet("com.trustwallet.app"), createWallet("global.safe"), ]; //copy pasta from Solidity code as Abi and Typechain does not export enum types enum BET_RESULT { WIN = 0, DRAW = 1, PENDING = 2, } interface AppProps { thirdwebClient: ThirdwebClient; } export default function App({ thirdwebClient }: AppProps) { console.log("*************App"); const account = useActiveAccount(); const [options, setOptions] = useState<Map<string, bigint>>(new Map()); const [error, setError] = useState<string>(""); const [status, setStatus] = useState<BET_RESULT>(BET_RESULT.PENDING); const [winner, setWinner] = useState<string | undefined>(undefined); const [fees, setFees] = useState<number>(0); const [betKeys, setBetKeys] = useState<bigint[]>([]); const [_bets, setBets] = useState<Marketpulse.BetStruct[]>([]); const reload = async () => { if (!account?.address) { console.log("No address..."); } else { const dataStatus = await readContract({ contract: getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }), method: "status", params: [], }); const dataWinner = await readContract({ contract: getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }), method: "winner", params: [], }); const dataFEES = await readContract({ contract: getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }), method: "FEES", params: [], }); const dataBetKeys = await readContract({ contract: getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }), method: "getBetKeys", params: [], }); setStatus(dataStatus as unknown as BET_RESULT); setWinner(dataWinner as unknown as string); setFees(Number(dataFEES as unknown as bigint) / 100); setBetKeys(dataBetKeys as unknown as bigint[]); console.log( "**********status, winner, fees, betKeys", status, winner, fees, betKeys ); } }; //first call to load data useEffect(() => { (() => reload())(); }, [account?.address]); //fetch bets useEffect(() => { (async () => { if (!betKeys || betKeys.length === 0) { console.log("no dataBetKeys"); setBets([]); } else { const bets = await Promise.all( betKeys.map( async (betKey) => (await readContract({ contract: getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }), method: "getBets", params: [betKey], })) as unknown as Marketpulse.BetStruct ) ); setBets(bets); //fetch options let newOptions = new Map(); setOptions(newOptions); bets.forEach((bet) => { if (newOptions.has(bet!.option)) { newOptions.set( bet!.option, newOptions.get(bet!.option)! + bet!.amount ); //acc } else { newOptions.set(bet!.option, bet!.amount); } }); setOptions(newOptions); console.log("options", newOptions); } })(); }, [betKeys]); const Ping = () => { // Comprehensive error handling const handlePing = async () => { try { const preparedContractCall = await prepareContractCall({ contract: getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }), method: "ping", params: [], }); console.log("preparedContractCall", preparedContractCall); const transaction = await sendTransaction({ transaction: preparedContractCall, account: account!, }); //wait for tx to be included on a block const receipt = await waitForReceipt({ client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), transactionHash: transaction.transactionHash, }); console.log("receipt :", receipt); setError(""); } catch (error) { const errorParsed = extractErrorDetails( error, Marketpulse__factory.abi ); setError(errorParsed.message); } }; return ( <span style={{ alignContent: "center", paddingLeft: 100 }}> <button onClick={handlePing}>Ping</button> {!error || error === "" ? <>🟢</> : <>🔴</>} </span> ); }; const BetFunction = () => { const [amount, setAmount] = useState<BigNumber>(BigNumber(0)); //in Ether decimals const [option, setOption] = useState("chiefs"); const runFunction = async () => { try { const contract = getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }); const preparedContractCall = await prepareContractCall({ contract, method: "bet", params: [option, parseEther(amount.toString(10))], value: parseEther(amount.toString(10)), }); const transaction = await sendTransaction({ transaction: preparedContractCall, account: account!, }); //wait for tx to be included on a block const receipt = await waitForReceipt({ client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), transactionHash: transaction.transactionHash, }); console.log("receipt :", receipt); await reload(); setError(""); } catch (error) { const errorParsed = extractErrorDetails( error, Marketpulse__factory.abi ); console.log("ERROR", error); setError(errorParsed.message); } }; const calculateOdds = (option: string, amount?: bigint): BigNumber => { //check option exists if (!options.has(option)) return new BigNumber(0); console.log( "actuel", options && options.size > 0 ? new BigNumber(options.get(option)!.toString()).toString() : 0, "total", new BigNumber( [...options.values()] .reduce((acc, newValue) => acc + newValue, amount ? amount : 0n) .toString() ).toString() ); return options && options.size > 0 ? new BigNumber(options.get(option)!.toString(10)) .plus( amount ? new BigNumber(amount.toString(10)) : new BigNumber(0) ) .div( new BigNumber( [...options.values()] .reduce( (acc, newValue) => acc + newValue, amount ? amount : 0n ) .toString(10) ) ) .plus(1) .minus(fees) : new BigNumber(0); }; return ( <span style={{ alignContent: "center", width: "100%" }}> {status && status === BET_RESULT.PENDING ? ( <> <h3>Choose team</h3> <select name="options" onChange={(e) => setOption(e.target.value)} value={option} > <option value="chiefs"> Chiefs</option> <option value="lions">Lions </option> </select> <h3>Amount</h3> <input type="number" id="amount" name="amount" required onChange={(e) => { if (e.target.value && !isNaN(Number(e.target.value))) { //console.log("e.target.value",e.target.value) setAmount(new BigNumber(e.target.value)); } }} /> <hr /> {account?.address ? <button onClick={runFunction}>Bet</button> : ""} <table style={{ fontWeight: "normal", width: "100%" }}> <tbody> <tr> <td style={{ textAlign: "left" }}>Avg price (decimal)</td> <td style={{ textAlign: "right" }}> {options && options.size > 0 ? calculateOdds(option, parseEther(amount.toString(10))) .toFixed(3) .toString() : 0} </td> </tr> <tr> <td style={{ textAlign: "left" }}>Potential return</td> <td style={{ textAlign: "right" }}> XTZ{" "} {amount ? calculateOdds(option, parseEther(amount.toString(10))) .multipliedBy(amount) .toFixed(6) .toString() : 0}{" "} ( {options && options.size > 0 ? calculateOdds(option, parseEther(amount.toString(10))) .minus(new BigNumber(1)) .multipliedBy(100) .toFixed(2) .toString() : 0} %) </td> </tr> </tbody> </table> </> ) : ( <> <span style={{ color: "#2D9CDB", fontSize: "1.125rem" }}> Outcome: {BET_RESULT[status]} </span> {winner ? <div style={{ color: "#858D92" }}>{winner}</div> : ""} </> )} </span> ); }; const resolve = async (option: string) => { try { const preparedContractCall = await prepareContractCall({ contract: getContract({ abi: Marketpulse__factory.abi, client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), address: CONTRACT_ADDRESS_JSON["MarketpulseModule#Marketpulse"], }), method: "resolveResult", params: [option, BET_RESULT.WIN], }); console.log("preparedContractCall", preparedContractCall); const transaction = await sendTransaction({ transaction: preparedContractCall, account: account!, }); //wait for tx to be included on a block const receipt = await waitForReceipt({ client: thirdwebClient, chain: defineChain(etherlinkTestnet.id), transactionHash: transaction.transactionHash, }); console.log("receipt :", receipt); await reload(); setError(""); } catch (error) { const errorParsed = extractErrorDetails(error, Marketpulse__factory.abi); setError(errorParsed.message); } }; return ( <> <header> <span style={{ display: "flex" }}> <h1>Market Pulse</h1> <div className="flex items-center gap-4"> <ConnectButton client={thirdwebClient} wallets={wallets} connectModal={{ size: "compact" }} chain={defineChain(etherlinkTestnet.id)} /> </div> </span> </header> <div id="content" style={{ display: "flex", paddingTop: 10 }}> <div style={{ width: "calc(66vw - 4rem)" }}> <img style={{ maxHeight: "40vh" }} src="https://zamrokk.github.io/marketpulse/images/graph.png" /> <hr /> <table style={{ width: "inherit" }}> <thead> <tr> <th>Outcome</th> <th>% chance</th> <th>action</th> </tr> </thead> <tbody> {options && options.size > 0 ? ( [...options.entries()].map(([option, amount]) => ( <tr key={option}> <td className="tdTable"> <div className="picDiv"> <img style={{ objectFit: "cover", height: "inherit" }} src={ "https://zamrokk.github.io/marketpulse/images/" + option + ".png" } ></img> </div> {option} </td> <td> {new BigNumber(amount.toString()) .div( new BigNumber( [...options.values()] .reduce((acc, newValue) => acc + newValue, 0n) .toString() ) ) .multipliedBy(100) .toFixed(2)} % </td> <td> {status && status === BET_RESULT.PENDING ? ( <button onClick={() => resolve(option)}>Winner</button> ) : ( "" )} </td> </tr> )) ) : ( <></> )} </tbody> </table> </div> <div style={{ width: "calc(33vw - 4rem)", boxShadow: "", margin: "1rem", borderRadius: "12px", border: "1px solid #344452", padding: "1rem", }} > <span className="tdTable">{<BetFunction />}</span> </div> </div> <footer> <h3>Errors</h3> <textarea readOnly rows={10} style={{ width: "100%" }} value={error} ></textarea> {account?.address ? <Ping /> : ""} </footer> </> ); }Explanations :
import { Marketpulse, Marketpulse__factory } from "./typechain-types";: Imports the contract ABI and contract structuresimport CONTRACT_ADDRESS_JSON from "./deployed_addresses.json";: Imports the address of the last deployed contract automaticallyconst wallets = [inAppWallet(...),createWallet(...)}: Configures the Thirdweb wallet connection. Look at the Thirdweb playground and play with the generator.useActiveAccount: Uses Thirdweb React hooks and functions as a wrapper over the Viem library to get the active account.const reload = async () => {: Refreshes the smart contract storage (status, winner, fees and mapping keys).useEffect...[betKeys]);: React effect that reloads all bets from the storage whenbetKeysis updated.const Ping = () => {: Checks that the smart contract interaction works. It can be removed in production deployments.const BetFunction = () => {: Sends your bet to the smart contract, passing along the correct amount of XTZ.const calculateOdds = (option: string, amount?: bigint): BigNumber => {: Calculates the odds, similar to the onchain function in the smart contract.
-
To fix the CSS for the page styling, replace the
./app/src/App.cssfile with this code:#root { margin: 0 auto; padding: 2rem; text-align: center; width: 100vw; height: calc(100vh - 4rem); } .logo { height: 6em; padding: 1.5em; will-change: filter; transition: filter 300ms; } .logo:hover { filter: drop-shadow(0 0 2em #646cffaa); } .logo.react:hover { filter: drop-shadow(0 0 2em #61dafbaa); } @keyframes logo-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; } } header { border-bottom: 1px solid #2c3f4f; height: 100px; } footer { border-top: 1px solid #2c3f4f; } hr { color: #2c3f4f; height: 1px; } .tdTable { align-items: center; gap: 1rem; width: 100%; flex: 3 1 0%; display: flex; font-weight: bold; } .picDiv { height: 40px; width: 40px; min-width: 40px; border-radius: 999px; position: relative; overflow: hidden; } .card { padding: 2em; } .read-the-docs { color: #888; } h1 { margin: unset; } -
Replace the
./app/src/index.cssfile with this code::root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; color-scheme: light dark; color: rgba(255, 255, 255, 0.87); background-color: #1d2b39; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } a { font-weight: 500; color: #646cff; text-decoration: inherit; } a:hover { color: #535bf2; } body { margin: 0; display: flex; place-items: center; min-width: 320px; min-height: 100vh; } h1 { font-size: 3.2em; line-height: 1.1; } button { border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; font-size: 1em; font-weight: 500; font-family: inherit; background-color: #2d9cdb; cursor: pointer; transition: border-color 0.25s; } button:hover { border-color: #646cff; } button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } select { width: inherit; font-size: 0.875rem; color: #858d92; border-color: #344452; transition: color 0.2s; text-align: center; border-width: 1px; border-style: solid; align-self: center; padding: 1rem 1rem; background: #1d2b39; outline: none; outline-color: currentcolor; outline-style: none; outline-width: medium; border-radius: 8px; } input { width: calc(100% - 35px); font-size: 0.875rem; color: #858d92; border-color: #344452; transition: color 0.2s; text-align: center; border-width: 1px; border-style: solid; align-self: center; padding: 1rem 1rem; background: #1d2b39; outline: none; outline-color: currentcolor; outline-style: none; outline-width: medium; border-radius: 8px; } @media (prefers-color-scheme: light) { :root { color: #213547; background-color: #ffffff; } a:hover { color: #747bff; } button { background-color: #f9f9f9; } } -
Run the application:
npm run dev -
In a web browser, click the Connect button to login with your wallet.
-
Click the Ping button at the bottom. It should stay green if you can interact with your smart contract with no error messages.
-
Run a betting scenario:
-
Select ** Chiefs** on the select box on the right corner, choose a small amount like 0.00001 XTZ, and click the Bet button.
-
Confirm the transaction in your wallet. Beware of funding your wallet with Etherlink testnet XTZ, otherwise you will receive an
OutOfFunderror -
Disconnect and connect with another account in your wallet.
-
Select **Lions ** on the select box on the right corner, choose a small amount like 0.00001 XTZ, and click the Bet button.
-
Confirm the transaction in your wallet.
Both teams have 50% of chance to win. Note: Default platform fees have been set to 10%, and the odds calculation take those fees into account.
-
Click one of the Winner buttons to resolve the poll.
The page's right-hand corner refreshes and displays the winner of the poll and the application automatically pays the winning bets.
-
Find your transaction
resolveResulton the Etherlink Testnet explorer athttps://testnet.explorer.etherlink.com. In the Transaction details>Internal txns tab, you should see, if you won something, the expected amount transferred to you from the smart contract address.
-