Developer Hub
🔮 For applications
Guides & Tutorials
Advanced integration
Tutorial: Add Live to Existing dApp
Step 5: Place a Live Bet

Adding Live. Step 5: Place a bet

Refer to the "Place a bet" and "Order errors" documentation to understand the process.

To place a live bet, you'll need:

  • Ensure allowance & approve token spending for the liveRelayer contract address.
  • Prepare the order data.
  • Sign a typed message (EIP712) using the user wallet.
  • Send the order to the Azuro API.
  • Retrieve the order and transaction status from the Azuro API.

Take a look at the source code of useLiveBetFee (opens in a new tab)

Get Relayer fee amount for live bets:

import { useQuery } from '@tanstack/react-query'
 
import { useChain } from '../contexts/chain'
import { getLiveBetFee } from '../utils/getLiveBetFee'
import { type ChainId, environments, getApiUrl } from '../config'
 
 
export type LiveBetFeeResponse = {
  gasLimit: number,
  gasPrice: number,
  betTokenRate: number,
  gasPriceInBetToken: number,
  slippage: number,
  gasAmount: number,
  relayerFeeAmount: string,
  beautyRelayerFeeAmount: string,
  symbol: string,
  decimals: number
}
 
export const getLiveBetFee = async (chainId: ChainId): Promise<LiveBetFeeResponse> => {
  const api = getApiUrl(chainId)
  const environment = environments[chainId]
 
  const response = await fetch(`${api}/orders/gas?environment=${environment}`)
  const data: LiveBetFeeResponse = await response.json()
 
  return data
}
 
type Props = {
  enabled?: boolean
}
 
export const useLiveBetFee = ({ enabled }: Props = { enabled: true }) => {
  const { appChain } = useChain()
 
  const queryFn = async () => {
    const {
      gasAmount,
      relayerFeeAmount,
      beautyRelayerFeeAmount,
    } = await getLiveBetFee(appChain.id)
 
    return {
      gasAmount: BigInt(gasAmount),
      relayerFeeAmount: BigInt(relayerFeeAmount),
      formattedRelayerFeeAmount: beautyRelayerFeeAmount,
    }
  }
 
  let { isFetching, data, refetch } = useQuery({
    queryKey: [ '/live-bet-fee', appChain.id ],
    queryFn,
    enabled,
    refetchOnWindowFocus: false,
    refetchInterval: 10000,
  })
 
  if (!enabled) {
    data = undefined
  }
 
  return {
    gasAmount: data?.gasAmount,
    relayerFeeAmount: data?.relayerFeeAmount,
    formattedRelayerFeeAmount: data?.formattedRelayerFeeAmount,
    refetch,
    loading: isFetching,
  }
}
ℹ️

For more information check Relayer fee section.

Take a look at the source code of usePrepareBet (opens in a new tab) which manages both prematch and live logic.

Here are the highlights of usePrepareBet for Live bets:

usePrepareBet.ts
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt, usePublicClient, useWalletClient } from 'wagmi'
import {
  parseUnits,
  encodeAbiParameters,
  parseAbiParameters,
  type Address, erc20Abi, type TransactionReceipt, type Hex, type TypedDataDomain } from 'viem'
import { useState } from 'react'
import { gnosis } from 'viem/chains'
 
import { useChain } from '../contexts/chain'
import { DEFAULT_DEADLINE, ODDS_DECIMALS, MAX_UINT_256, liveHostAddress, getApiUrl, liveBetAmount, environments } from '../config'
import type { Selection } from '../global'
import { useBetsCache } from './useBetsCache'
import { useLiveBetFee } from './useLiveBetFee'
 
 
enum LiveOrderState {
  Created = 'Created',
  Pending = 'Pending',
  Sent = 'Sent',
  Accepted = 'Accepted',
  Rejected = 'Rejected'
}
 
type LiveCreateOrderResponse = {
  id: string
  state: LiveOrderState
  errorMessage?: string
}
 
type LiveGetOrderResponse = {
  txHash: string
  odds: string
  betId: string
} & LiveCreateOrderResponse
 
type Props = {
  betAmount: string
  slippage: number
  affiliate: Address
  selections: Selection[]
  odds: Record<string, number>
  totalOdds: number
  liveEIP712Attention?: string
  deadline?: number
  onSuccess?(receipt?: TransactionReceipt): void
  onError?(err?: Error): void
}
 
export const usePrepareBet = (props: Props) => {
  const { betAmount, slippage, deadline, affiliate, selections, odds, totalOdds, liveEIP712Attention, onSuccess, onError } = props
 
  const isLiveBet = selections.some(({ coreAddress }) => coreAddress === liveHostAddress)
 
  const account = useAccount()
  const publicClient = usePublicClient()
  const walletClient = useWalletClient()
  const { appChain, contracts, betToken } = useChain()
  const { addBet } = useBetsCache()
  const {
    relayerFeeAmount: rawRelayerFeeAmount,
    formattedRelayerFeeAmount: relayerFeeAmount,
    loading: isRelayerFeeFetching,
  } = useLiveBetFee({
    enabled: isLiveBet,
  })
  const [ isLiveBetPending, setLiveBetPending ] = useState(false)
  const [ isLiveBetProcessing, setLiveBetProcessing ] = useState(false)
 
  const approveAddress = isLiveBet ? contracts.liveRelayer?.address : contracts.proxyFront.address
 
  const allowanceTx = useReadContract({
    chainId: appChain.id,
    address: betToken.address,
    abi: erc20Abi,
    functionName: 'allowance',
    args: [
      account.address!,
      approveAddress!,
    ],
    query: {
      enabled: Boolean(account.address) && Boolean(approveAddress),
    },
  })
 
  const approveTx = useWriteContract()
 
  const approveReceipt = useWaitForTransactionReceipt({
    hash: approveTx.data,
  })
 
  const isApproveRequired = Boolean(
    allowanceTx.data !== undefined
    && +betAmount
    && allowanceTx.data < parseUnits(isLiveBet ? String((+betAmount + +relayerFeeAmount!)) : betAmount, betToken.decimals)
  )
 
  const approve = async () => {
    const hash = await approveTx.writeContractAsync({
      address: betToken.address!,
      abi: erc20Abi,
      functionName: 'approve',
      args: [
        approveAddress!,
        MAX_UINT_256,
      ],
    })
    await publicClient!.waitForTransactionReceipt({
      hash,
    })
    allowanceTx.refetch()
  }
 
  const betTx = useWriteContract()
 
  const betReceipt = useWaitForTransactionReceipt({
    hash: betTx.data,
  })
 
  const placeBet = async () => {
    if (!totalOdds) {
      return
    }
 
    const fixedAmount = +parseFloat(String(betAmount)).toFixed(betToken.decimals)
    const rawAmount = parseUnits(`${fixedAmount}`, betToken.decimals)
 
    const minOdds = 1 + (+totalOdds - 1) * (100 - slippage) / 100
    const fixedMinOdds = +parseFloat(String(minOdds)).toFixed(ODDS_DECIMALS)
    const rawMinOdds = parseUnits(`${fixedMinOdds}`, ODDS_DECIMALS)
    const rawDeadline = BigInt(Math.floor(Date.now() / 1000) + (deadline || DEFAULT_DEADLINE))
 
    let txHash: Hex
 
    try {
      if (isLiveBet) {
        setLiveBetPending(true)
        const { conditionId, outcomeId } = selections[0]!
 
        const order = {
          bet: {
            attention: liveEIP712Attention || 'By signing this transaction, I agree to place a bet for a live event on \'Azuro SDK Example',
            affiliate,
            core: contracts.liveCore!.address,
            amount: String(rawAmount),
            chainId: appChain.id,
            conditionId: conditionId,
            outcomeId: +outcomeId,
            minOdds: String(rawMinOdds),
            nonce: String(Date.now()),
            expiresAt: Math.floor(Date.now() / 1000) + 2000,
            relayerFeeAmount: String(rawRelayerFeeAmount),
          },
        }
 
        const EIP712Domain: TypedDataDomain = {
          name: 'Live Betting',
          version: '1.0.0',
          chainId: appChain.id,
          verifyingContract: contracts.liveCore!.address,
        }
 
        const clientBetDataTypes = {
          ClientBetData: [
            { name: 'attention', type: 'string' },
            { name: 'affiliate', type: 'address' },
            { name: 'core', type: 'address' },
            { name: 'amount', type: 'uint128' },
            { name: 'nonce', type: 'uint256' },
            { name: 'conditionId', type: 'uint256' },
            { name: 'outcomeId', type: 'uint64' },
            { name: 'minOdds', type: 'uint64' },
            { name: 'expiresAt', type: 'uint256' },
            { name: 'chainId', type: 'uint256' },
            { name: 'relayerFeeAmount', type: 'uint256' },
          ],
        } as const
 
        const signature = await walletClient!.data!.signTypedData({
          account: account.address,
          domain: EIP712Domain,
          primaryType: 'ClientBetData',
          types: clientBetDataTypes,
          message: {
            attention: order.bet.attention,
            affiliate: order.bet.affiliate,
            core: order.bet.core,
            amount: BigInt(order.bet.amount),
            nonce: BigInt(order.bet.nonce),
            conditionId: BigInt(order.bet.conditionId),
            outcomeId: BigInt(order.bet.outcomeId),
            minOdds: BigInt(order.bet.minOdds),
            expiresAt: BigInt(order.bet.expiresAt),
            chainId: BigInt(order.bet.chainId),
            relayerFeeAmount: BigInt(order.bet.relayerFeeAmount),
          },
        })
        setLiveBetPending(false)
        setLiveBetProcessing(true)
 
        const signedBet = {
          environment: environments[appChain.id],
          bettor: account.address!.toLowerCase(),
          data: order,
          bettorSignature: signature,
        }
 
        const apiUrl = getApiUrl(appChain.id)
 
        const createOrderResponse = await fetch(`${apiUrl}/orders`, {
          method: 'POST',
          headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(signedBet),
        })
 
        const {
          id: orderId,
          state: newOrderState,
          errorMessage,
        }: LiveCreateOrderResponse = await createOrderResponse.json()
 
        if (newOrderState === LiveOrderState.Created) {
          txHash = await new Promise<Hex>((res, rej) => {
            const interval = setInterval(async () => {
              const getOrderResponse = await fetch(`${apiUrl}/orders/${orderId}`)
              const { state, txHash }: LiveGetOrderResponse = await getOrderResponse.json()
 
              if (state === LiveOrderState.Rejected) {
                clearInterval(interval)
                rej()
              }
 
              if (txHash) {
                clearInterval(interval)
                res(txHash as Hex)
              }
            }, 1000)
          })
 
          setLiveBetProcessing(false)
        }
        else {
          throw Error(errorMessage)
        }
      }
      else {
        let coreAddress: Address
        let data: Address
 
        if (selections.length > 1) {
          coreAddress = contracts.prematchComboCore.address
 
          const tuple: [ bigint, bigint ][] = selections.map(({ conditionId, outcomeId }) => [
            BigInt(conditionId),
            BigInt(outcomeId),
          ])
 
          data = encodeAbiParameters(
            parseAbiParameters('(uint256, uint64)[]'),
            [
              tuple,
            ]
          )
        }
        else {
          coreAddress = contracts.prematchCore.address
 
          const { conditionId, outcomeId } = selections[0]!
 
          data = encodeAbiParameters(
            parseAbiParameters('uint256, uint64'),
            [
              BigInt(conditionId),
              BigInt(outcomeId),
            ]
          )
        }
        txHash = await betTx.writeContractAsync({
          address: contracts.proxyFront.address,
          abi: contracts.proxyFront.abi,
          functionName: 'bet',
          value: BigInt(0),
          args: [
            contracts.lp.address,
            [
              {
                core: coreAddress,
                amount: rawAmount,
                expiresAt: rawDeadline,
                extraData: {
                  affiliate,
                  minOdds: rawMinOdds,
                  data,
                },
              },
            ],
          ],
        })
      }
 
      const receipt = await publicClient?.waitForTransactionReceipt({
        hash: txHash,
      })
 
      if (receipt) {
        addBet({
          receipt,
          bet: {
            amount: `${fixedAmount}`,
            selections,
            odds,
          },
        })
      }
 
      if (onSuccess) {
        onSuccess(receipt)
      }
    }
    catch (err) {
      setLiveBetPending(false)
      setLiveBetProcessing(false)
 
      if (onError) {
        onError(err as any)
      }
    }
  }
 
  const submit = () => {
    if (isApproveRequired) {
      return approve()
    }
 
    return placeBet()
  }
 
  return {
    isAllowanceLoading: allowanceTx.isLoading,
    isApproveRequired,
    isRelayerFeeFetching,
    submit,
    relayerFeeAmount,
    approveTx: {
      isPending: approveTx.isPending,
      isProcessing: approveReceipt.isLoading,
    },
    betTx: {
      isPending: betTx.isPending || isLiveBetPending,
      isProcessing: betReceipt.isLoading || isLiveBetProcessing,
    },
  }
}
⚠️

Please ensure that the order amount contains maximum 2 decimal signs, otherwise our backend will reject the order. Examples:

  • 15 USDT - valid ✅
  • 15.2 USDT - valid ✅
  • 15.25 USDT - valid ✅
  • 15.125 USDT - invalid ❌
ℹ️

To obtain the minOdds, utilize the calcLiveOdds function from our Toolkit in conjunction with the odds data retrieved from the Socket API.

import { calcLiveOdds } from '@azuro-org/toolkit'
 
socket.onmessage = (message) => {
  const data = JSON.parse(message.data.toString())
 
  data.forEach(data => {
    if (data.outcomes) { // new odds received
      const { id: conditionId, reinforcement, margin, winningOutcomesCount } = data
 
      const oddsData = {
        conditionId: conditionId,
        reinforcement: +reinforcement,
        margin: +margin,
        winningOutcomesCount: +winningOutcomesCount,
        outcomes: {},
      }
 
      oddsData.outcomes = data.outcomes.reduce((acc, { id, odds, clearOdds, maxStake }) => {
        acc[id] = {
          odds,
          clearOdds,
        }
 
        return acc
      }, {})
 
      const minOdds = calcLiveOdds({
        selection: { conditionId, outcomeId, coreAddress },
        betAmount: '10', // 10 USDT
        oddsData,
      })
    }
  });
}
ℹ️

nonce is an integer number that represents an order identifier and it should:

  1. Be unique for the bettor's wallet address.
  2. Be higher than previous used value.

So we recommend to use current timestamp (in milliseconds) to prevent any collisions, but you are welcome to come up with your solution.

Like another potentially big integers we pass it as a string for safety.

⚠️

Consider that before each order request you'll need to ensure that the user has approved enough tokens for bet itself and a fee for relayer that will compensate his native currency spending. So the spending limit should be equal or higher than order.amount + order.relayerFeeAmount.