Developer Hub
🔮 For applications
Guides & Tutorials
Tutorial: build your dApp
Step 6: Allow Users to Place Bets

Allow Users to Place Bets

In order to place a bet, the user needs to select the outcome of a sporting event and input the amount they want to bet. To facilitate this process in the UI, we will use a betslip. When the user clicks on an outcome button, a betslip will open. The user can then enter the amount they want to bet, view a summary of their bet, and submit the transaction.

Create Context

Implementing Context is crucial to enable opening the betslip from any section of our application. Create filder src/context and file betslip.tsx inside.

src/context/betslip.tsx
import { useContext, createContext, useState, useEffect } from 'react'
import { useBaseBetslip } from '@azuro-org/sdk'
 
 
export type BetslipContextValue = {
  isOpen: boolean
  setOpen: (value: boolean) => void
}
 
export const BetslipContext = createContext<BetslipContextValue | null>(null)
 
export const useBetslip = () => {
  return useContext(BetslipContext) as BetslipContextValue
}
 
type Props = {
  children: React.ReactNode
}
 
export const BetslipProvider: React.FC<Props> = ({ children }) => {
  const { items } = useBaseBetslip()
  const [ isOpen, setOpen ] = useState(false)
 
  useEffect(() => {
    if (items.length) {
      setOpen(true)
    }
  }, [ items ])
 
  const value = {
    isOpen,
    setOpen,
  }
 
  return (
    <BetslipContext.Provider value={value}>
      {children}
    </BetslipContext.Provider>
  );
}

And add this Context to Providers component:

src/components/Providers.tsx
'use client'
 
import React from 'react'
import { AzuroSDKProvider, ChainId } from '@azuro-org/sdk'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RainbowKitProvider, getDefaultWallets, getDefaultConfig } from '@rainbow-me/rainbowkit'
import { WagmiProvider } from 'wagmi'
import { polygon, gnosis } from 'viem/chains'
 
import { BetslipProvider } from '@/context/betslip'
 
 
const { wallets } = getDefaultWallets()
 
const chains = [
  polygon,
  gnosis,
] as const
 
const wagmiConfig = getDefaultConfig({
  appName: 'Azuro',
  appIcon: 'https://path-to-your-icon.com/icon.png',
  projectId: '2f82a1608c73932cfc64ff51aa38a87b', // get your own project ID - https://cloud.walletconnect.com/sign-in
  wallets,
  chains,
})
 
const queryClient = new QueryClient()
 
type ProvidersProps = {
  children: React.ReactNode
  initialChainId?: string
  initialLiveState?: boolean
}
 
export function Providers(props: ProvidersProps) {
  const { children, initialChainId, initialLiveState } = props
 
  const chainId = initialChainId
    ? chains.find(chain => chain.id === +initialChainId) ? +initialChainId as ChainId : polygon.id
    : polygon.id
 
  return (
    <WagmiProvider config={wagmiConfig}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <AzuroSDKProvider initialChainId={chainId} initialLiveState={initialLiveState}>
            <BetslipProvider>
              {children}
            </BetslipProvider>
          </AzuroSDKProvider>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  )
}

Create the Betslip

Create file Betslip.tsx in src/components.

AmountInput

Here we will show user's balance of betting token and handle amount input for bet.

function AmountInput() {
  const { betAmount, changeBetAmount} = useDetailedBetslip()
  const { betToken } = useChain()
  const { loading: isBalanceFetching, balance } = useBetTokenBalance()
 
  return (
    <div className="mt-4 pt-4 border-t border-zinc-300 space-y-2">
      <div className="flex items-center justify-between">
        <span className="text-md text-zinc-400">Wallet balance</span>
        <span className="text-md font-semibold">
          {
            isBalanceFetching ? (
              <>Loading...</>
            ) : (
              balance !== undefined ? (
                <>{(+balance).toFixed(2)} {betToken.symbol}</>
              ) : (
                <>-</>
              )
            )
          }
        </span>
      </div>
      <div className="flex items-center justify-between">
        <span className="text-md text-zinc-400">Bet amount</span>
        <input
          className="w-[140px] py-2 px-4 border border-zinc-400 text-md text-right font-semibold rounded-md"
          type="number"
          placeholder="Bet amount"
          value={betAmount}
          onChange={(event) => changeBetAmount(event.target.value)}
        />
      </div>
    </div>
  )
}

SubmitButton

To submit a bet we will create a button, which will prepare betting transaction and handle all the business logic.

const errorPerDisableReason = {
  [BetslipDisableReason.ComboWithForbiddenItem]: 'One or more conditions can\'t be used in combo',
  [BetslipDisableReason.BetAmountGreaterThanMaxBet]: 'Bet amount exceeds max bet',
  [BetslipDisableReason.BetAmountLowerThanMinBet]: 'Bet amount lower than min bet',
  [BetslipDisableReason.ComboWithLive]: 'Live outcome can\'t be used in combo',
  [BetslipDisableReason.ConditionStatus]: 'One or more outcomes have been removed or suspended. Review your betslip and remove them.',
  [BetslipDisableReason.PrematchConditionInStartedGame]: 'Game has started',
} as const
 
const SubmitButton: React.FC = () => {
  const { appChain, isRightNetwork } = useChain()
  const { items, clear } = useBaseBetslip()
  const { betAmount, odds, totalOdds, isStatusesFetching, isOddsFetching, isBetAllowed } = useDetailedBetslip()
  const { loading: isBalanceFetching, balance } = useBetTokenBalance()
 
  const {
    submit,
    approveTx,
    betTx,
    isRelayerFeeLoading,
    isAllowanceLoading,
    isApproveRequired,
  } = usePrepareBet({
    betAmount,
    slippage: 10,
    affiliate: '0x0000000000000000000000000000000000000000', // your affiliate address
    selections: items,
    odds,
    totalOdds,
    onSuccess: () => {
      clear()
    },
  })
 
  const isPending = approveTx.isPending || betTx.isPending
  const isProcessing = approveTx.isProcessing  || betTx.isProcessing
 
  if (!isRightNetwork) {
    return (
      <div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
        Switch network to <b>{appChain.name}</b> in your wallet
      </div>
    )
  }
 
  const isEnoughBalance = isBalanceFetching || !Boolean(+betAmount) ? true : Boolean(+balance! > +betAmount)
 
  const isLoading = (
    isOddsFetching
    || isBalanceFetching
    || isStatusesFetching
    || isAllowanceLoading
    || isPending
    || isProcessing
    || isRelayerFeeLoading
  )
 
  const isDisabled = (
    isLoading
    || !isBetAllowed
    || !isEnoughBalance
    || !+betAmount
  )
 
  let title
 
  if (isPending) {
    title = 'Waiting for approval'
  }
  else if (isProcessing) {
    title = 'Processing...'
  }
  else if (isLoading) {
    title = 'Loading...'
  }
  else if (isApproveRequired) {
    title = 'Approve'
  }
  else {
    title = 'Place Bet'
  }
 
  return (
    <div className="mt-6">
      {
        !isEnoughBalance && (
          <div className="mb-1 text-red-500 text-center font-semibold">
            Not enough balance.
          </div>
        )
      }
      <button
        className={cx('w-full py-3.5 text-white font-semibold text-center rounded-xl', {
          'bg-blue-500 hover:bg-blue-600 transition shadow-md': !isDisabled,
          'bg-zinc-300 cursor-not-allowed': isDisabled,
        })}
        disabled={isDisabled}
        onClick={submit}
      >
        {title}
      </button>
    </div>
  )
}

Betslip itself

Now we have components to create a betslip to place a bet. Here we'll need your affiliate address, which we described here.

Before first bet each user must approve token spending by Azuro Liquidity Pool contract. It's already handled by usePrepareBet hook from Azuro SDK, so we need just use it.

function Content() {
  const account = useAccount()
  const { items, clear, removeItem } = useBaseBetslip()
  const { betAmount, odds, totalOdds, statuses, disableReason, isStatusesFetching, isOddsFetching, isLiveBet } = useDetailedBetslip()
  const { formattedRelayerFeeAmount, loading: isRelayerFeeLoading } = useLiveBetFee({
    enabled: isLiveBet,
  })
 
  return (
    <div className="bg-zinc-100 p-4 mb-4 rounded-md w-full max-h-[90vh] overflow-auto border border-solid">
      <div className="flex items-center justify-between mb-2">
        <div className="">Betslip {items.length > 1 ? 'Combo' : 'Single'} {items.length ? `(${items.length})`: ''}</div>
        {
          Boolean(items.length) && (
            <button onClick={clear}>Remove All</button>
          )
        }
      </div>
      {
        Boolean(items.length) ? (
          <>
            <div className="max-h-[300px] overflow-auto">
              {
                items.map(item => {
                  const { game: { gameId, startsAt, sportName, leagueName, participants }, conditionId, outcomeId } = item
 
                  const marketName = getMarketName({ outcomeId })
                  const selection = getSelectionName({ outcomeId, withPoint: true })
 
                  const isLock = !isStatusesFetching && statuses[conditionId] !== ConditionStatus.Created
 
                  return (
                    <div key={gameId} className="bg-zinc-50 p-2 rounded-md mt-2 first-of-type:mt-0">
                      <div className="flex items-center justify-between mb-2">
                        <div>{sportName} / {leagueName}</div>
                        <button onClick={() => removeItem(gameId)}>Remove</button>
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        {
                          participants.map(({ image, name }) => (
                            <div key={name} className="flex items-center ml-2 first-of-type:ml-0">
                              <div className="flex items-center justify-center w-8 h-8 p-1 mr-2 border border-zinc-300 rounded-full">
                                {
                                  Boolean(image) && (
                                    <img className="w-full h-full" src={image!} alt="" />
                                  )
                                }
                              </div>
                              <span className="text-md">{name}</span>
                            </div>
                          ))
                        }
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        <span className="font-bold">Start Date: </span>
                        {dayjs(+startsAt * 1000).format('DD MMM HH:mm')}
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        <span className="font-bold">Market: </span>
                        {marketName}
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        <span className="font-bold">Selection: </span>
                        {selection}
                      </div>
                      <div className="flex items-center justify-between ">
                        <span className="font-bold">Odds: </span>
                        {
                          isOddsFetching ? (
                            <div className="span">Loading...</div>
                          ) : (
                            odds[`${conditionId}-${outcomeId}`]
                          )
                        }
                      </div>
                      {
                        isLock && (
                          <div className="text-orange-200 text-center">Outcome removed or suspended</div>
                        )
                      }
                    </div>
                  )
                })
              }
            </div>
            <div className="flex items-center justify-between mt-4">
              <span className="text-md text-zinc-400">Total Odds:</span>
              <span className="text-md font-semibold">
                {
                  isOddsFetching ? (
                    <>Loading...</>
                  ) : (
                    <>{ totalOdds }</>
                  )
                }
              </span>
            </div>
            <div className="flex items-center justify-between mt-4">
              <span className="text-md text-zinc-400">Possible win:</span>
              <span className="text-md font-semibold">
                {
                  isOddsFetching ? (
                    <>Loading...</>
                  ) : (
                    <>{totalOdds * +betAmount}</>
                  )
                }
              </span>
            </div>
            {
              Boolean(isRelayerFeeLoading || formattedRelayerFeeAmount) && (
                <div className="flex items-center justify-between mt-4">
                  <span className="text-md text-zinc-400">Relayer fee:</span>
                  <span className="text-md font-semibold">
                    {
                      isRelayerFeeLoading ? (
                        <>Loading...</>
                      ) : (
                        <>{formattedRelayerFeeAmount}</>
                      )
                    }
                  </span>
                </div>
              )
            }
            <AmountInput />
            {
              Boolean(disableReason) && (
                <div className="mb-1 text-red-500 text-center font-semibold">
                  {errorPerDisableReason[disableReason!]}
                </div>
              )
            }
            {
              account?.address ? (
                <SubmitButton />
              ) : (
                <div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
                Connect your wallet
              </div>
              )
            }
          </>
        ) : (
          <div>Empty</div>
        )
      }
    </div>
  )
}
 
export function Betslip() {
  const { isOpen, setOpen } = useBetslip()
  const { items } = useBaseBetslip()
 
  return (
    <div className="fixed bottom-4 right-4 w-full max-w-full md:max-w-sm">
      {
        isOpen && (
          <Content />
        )
      }
      <button
        className="flex items-center py-2 px-4 bg-zinc-100 whitespace-nowrap rounded-full ml-auto"
        onClick={() => setOpen(!isOpen)}
      >
        Betslip {items.length || ''}
      </button>
    </div>
  )
}

Full Betslip code

src/components/Betslip.tsx
'use client'
import React from 'react'
import cx from 'clsx'
import {
  ConditionStatus,
  useBaseBetslip,
  useBetTokenBalance,
  useChain,
  useDetailedBetslip,
  BetslipDisableReason,
  useLiveBetFee,
  usePrepareBet,
} from '@azuro-org/sdk'
import { getMarketName, getSelectionName } from '@azuro-org/dictionaries'
import { useAccount } from 'wagmi'
import dayjs from 'dayjs'
 
import { useBetslip } from '@/context/betslip'
 
 
function AmountInput() {
  const { betAmount, changeBetAmount, maxBet, minBet } = useDetailedBetslip()
  const { betToken } = useChain()
  const { loading: isBalanceFetching, balance } = useBetTokenBalance()
 
  return (
    <div className="mt-4 pt-4 border-t border-zinc-300 space-y-2">
      <div className="flex items-center justify-between">
        <span className="text-md text-zinc-400">Wallet balance:</span>
        <span className="text-md font-semibold">
          {
            isBalanceFetching ? (
              <>Loading...</>
            ) : (
              balance !== undefined ? (
                <>{(+balance).toFixed(2)} {betToken.symbol}</>
              ) : (
                <>-</>
              )
            )
          }
        </span>
      </div>
      {
        Boolean(maxBet) && <div className="flex items-center justify-between">
          <span className="text-md text-zinc-400">Max bet amount:</span>
          <span className="text-md font-semibold">{maxBet} {betToken.symbol}</span>
        </div>
      }
      {
        Boolean(minBet) && <div className="flex items-center justify-between">
          <span className="text-md text-zinc-400">Min bet amount:</span>
          <span className="text-md font-semibold">{minBet} {betToken.symbol}</span>
        </div>
      }
      <div className="flex items-center justify-between">
        <span className="text-md text-zinc-400">Bet amount</span>
        <input
          className="w-[140px] py-2 px-4 border border-zinc-400 text-md text-right font-semibold rounded-md"
          type="number"
          placeholder="Bet amount"
          value={betAmount}
          onChange={(event) => changeBetAmount(event.target.value)}
        />
      </div>
    </div>
  )
}
 
const errorPerDisableReason = {
  [BetslipDisableReason.ComboWithForbiddenItem]: 'One or more conditions can\'t be used in combo',
  [BetslipDisableReason.BetAmountGreaterThanMaxBet]: 'Bet amount exceeds max bet',
  [BetslipDisableReason.BetAmountLowerThanMinBet]: 'Bet amount lower than min bet',
  [BetslipDisableReason.ComboWithLive]: 'Live outcome can\'t be used in combo',
  [BetslipDisableReason.ConditionStatus]: 'One or more outcomes have been removed or suspended. Review your betslip and remove them.',
  [BetslipDisableReason.PrematchConditionInStartedGame]: 'Game has started',
} as const
 
const SubmitButton: React.FC = () => {
  const { appChain, isRightNetwork } = useChain()
  const { items, clear } = useBaseBetslip()
  const { betAmount, odds, totalOdds, isStatusesFetching, isOddsFetching, isBetAllowed } = useDetailedBetslip()
  const { loading: isBalanceFetching, balance } = useBetTokenBalance()
 
  const {
    submit,
    approveTx,
    betTx,
    isRelayerFeeLoading,
    isAllowanceLoading,
    isApproveRequired,
  } = usePrepareBet({
    betAmount,
    slippage: 10,
    affiliate: '0x0000000000000000000000000000000000000000', // your affiliate address
    selections: items,
    odds,
    totalOdds,
    onSuccess: () => {
      clear()
    },
  })
 
  const isPending = approveTx.isPending || betTx.isPending
  const isProcessing = approveTx.isProcessing  || betTx.isProcessing
 
  if (!isRightNetwork) {
    return (
      <div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
        Switch network to <b>{appChain.name}</b> in your wallet
      </div>
    )
  }
 
  const isEnoughBalance = isBalanceFetching || !Boolean(+betAmount) ? true : Boolean(+balance! > +betAmount)
 
  const isLoading = (
    isOddsFetching
    || isBalanceFetching
    || isStatusesFetching
    || isAllowanceLoading
    || isPending
    || isProcessing
    || isRelayerFeeLoading
  )
 
  const isDisabled = (
    isLoading
    || !isBetAllowed
    || !isEnoughBalance
    || !+betAmount
  )
 
  let title
 
  if (isPending) {
    title = 'Waiting for approval'
  }
  else if (isProcessing) {
    title = 'Processing...'
  }
  else if (isLoading) {
    title = 'Loading...'
  }
  else if (isApproveRequired) {
    title = 'Approve'
  }
  else {
    title = 'Place Bet'
  }
 
  return (
    <div className="mt-6">
      {
        !isEnoughBalance && (
          <div className="mb-1 text-red-500 text-center font-semibold">
            Not enough balance.
          </div>
        )
      }
      <button
        className={cx('w-full py-3.5 text-white font-semibold text-center rounded-xl', {
          'bg-blue-500 hover:bg-blue-600 transition shadow-md': !isDisabled,
          'bg-zinc-300 cursor-not-allowed': isDisabled,
        })}
        disabled={isDisabled}
        onClick={submit}
      >
        {title}
      </button>
    </div>
  )
}
 
function Content() {
  const account = useAccount()
  const { items, clear, removeItem } = useBaseBetslip()
  const { betAmount, odds, totalOdds, statuses, disableReason, isStatusesFetching, isOddsFetching, isLiveBet } = useDetailedBetslip()
  const { formattedRelayerFeeAmount, loading: isRelayerFeeLoading } = useLiveBetFee({
    enabled: isLiveBet,
  })
 
  return (
    <div className="bg-zinc-100 p-4 mb-4 rounded-md w-full max-h-[90vh] overflow-auto border border-solid">
      <div className="flex items-center justify-between mb-2">
        <div className="">Betslip {items.length > 1 ? 'Combo' : 'Single'} {items.length ? `(${items.length})`: ''}</div>
        {
          Boolean(items.length) && (
            <button onClick={clear}>Remove All</button>
          )
        }
      </div>
      {
        Boolean(items.length) ? (
          <>
            <div className="max-h-[300px] overflow-auto">
              {
                items.map(item => {
                  const { game: { gameId, startsAt, sportName, leagueName, participants }, conditionId, outcomeId } = item
 
                  const marketName = getMarketName({ outcomeId })
                  const selection = getSelectionName({ outcomeId, withPoint: true })
 
                  const isLock = !isStatusesFetching && statuses[conditionId] !== ConditionStatus.Created
 
                  return (
                    <div key={gameId} className="bg-zinc-50 p-2 rounded-md mt-2 first-of-type:mt-0">
                      <div className="flex items-center justify-between mb-2">
                        <div>{sportName} / {leagueName}</div>
                        <button onClick={() => removeItem(gameId)}>Remove</button>
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        {
                          participants.map(({ image, name }) => (
                            <div key={name} className="flex items-center ml-2 first-of-type:ml-0">
                              <div className="flex items-center justify-center w-8 h-8 p-1 mr-2 border border-zinc-300 rounded-full">
                                {
                                  Boolean(image) && (
                                    <img className="w-full h-full" src={image!} alt="" />
                                  )
                                }
                              </div>
                              <span className="text-md">{name}</span>
                            </div>
                          ))
                        }
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        <span className="font-bold">Start Date: </span>
                        {dayjs(+startsAt * 1000).format('DD MMM HH:mm')}
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        <span className="font-bold">Market: </span>
                        {marketName}
                      </div>
                      <div className="flex items-center justify-between mb-2">
                        <span className="font-bold">Selection: </span>
                        {selection}
                      </div>
                      <div className="flex items-center justify-between ">
                        <span className="font-bold">Odds: </span>
                        {
                          isOddsFetching ? (
                            <div className="span">Loading...</div>
                          ) : (
                            odds[`${conditionId}-${outcomeId}`]
                          )
                        }
                      </div>
                      {
                        isLock && (
                          <div className="text-orange-200 text-center">Outcome removed or suspended</div>
                        )
                      }
                    </div>
                  )
                })
              }
            </div>
            <div className="flex items-center justify-between mt-4">
              <span className="text-md text-zinc-400">Total Odds:</span>
              <span className="text-md font-semibold">
                {
                  isOddsFetching ? (
                    <>Loading...</>
                  ) : (
                    <>{ totalOdds }</>
                  )
                }
              </span>
            </div>
            <div className="flex items-center justify-between mt-4">
              <span className="text-md text-zinc-400">Possible win:</span>
              <span className="text-md font-semibold">
                {
                  isOddsFetching ? (
                    <>Loading...</>
                  ) : (
                    <>{totalOdds * +betAmount}</>
                  )
                }
              </span>
            </div>
            {
              Boolean(isRelayerFeeLoading || formattedRelayerFeeAmount) && (
                <div className="flex items-center justify-between mt-4">
                  <span className="text-md text-zinc-400">Relayer fee:</span>
                  <span className="text-md font-semibold">
                    {
                      isRelayerFeeLoading ? (
                        <>Loading...</>
                      ) : (
                        <>{formattedRelayerFeeAmount}</>
                      )
                    }
                  </span>
                </div>
              )
            }
            <AmountInput />
            {
              Boolean(disableReason) && (
                <div className="mb-1 text-red-500 text-center font-semibold">
                  {errorPerDisableReason[disableReason!]}
                </div>
              )
            }
            {
              account?.address ? (
                <SubmitButton />
              ) : (
                <div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
                Connect your wallet
              </div>
              )
            }
          </>
        ) : (
          <div>Empty</div>
        )
      }
    </div>
  )
}
 
export function Betslip() {
  const { isOpen, setOpen } = useBetslip()
  const { items } = useBaseBetslip()
 
  return (
    <div className="fixed bottom-4 right-4 w-full max-w-full md:max-w-sm">
      {
        isOpen && (
          <Content />
        )
      }
      <button
        className="flex items-center py-2 px-4 bg-zinc-100 whitespace-nowrap rounded-full ml-auto"
        onClick={() => setOpen(!isOpen)}
      >
        Betslip {items.length || ''}
      </button>
    </div>
  )
}
⚠️

Don't forget to add export to src/components/index.ts.

Add the Betslip to UI

Update src/app/layout.tsx:

src/app/layout.tsx
import '@rainbow-me/rainbowkit/styles.css'
import './globals.css'
import { Providers, Header, Betslip } from '@/components'
import { cookies } from 'next/headers'
 
 
export default function RootLayout(props: { children: React.ReactNode }) {
  const { children } = props
  const cookieStore = cookies()
 
  const initialChainId = cookieStore.get('appChainId')?.value
  const initialLiveState = JSON.parse(cookieStore.get('live')?.value || 'false')
 
  return (
    <html lang="en">
      <body>
        <Providers initialChainId={initialChainId} initialLiveState={initialLiveState}>
          <Header />
          <main className="pt-5 pb-10">
            {children}
            <Betslip />
          </main>
        </Providers>
      </body>
    </html>
  )
}
 

Now click on any outcome button should open the Betslip

Learn more

In our tutorial we're building simple betting app. If you're ready to go deeper, learn things from SDK that we used in this section: useChain, useBetTokenBalance