Skip to Content

getBetsReport

Fetches every bet matching a filter and aggregates it into a turnover / returns / profit / ROI report, broken down by settlement token.

getBetsReport is framework-agnostic - it has no dependency on React and can be called from any JS/TS frontend, a server, or a script, which is why it lives in @azuro-org/toolkit rather than @azuro-org/sdk. The SDK’s useBetsReport is a thin TanStack Query wrapper around it.

ℹ️

The filter is turned into a subgraph query by the same toGraphBetsWhere helper a bet list uses, so a report built from the same filter always describes exactly the bets a list shows.

Bets are walked with a timestamp cursor rather than skip, which sidesteps the subgraph’s skip <= 5000 cap, so the report can be exact over a bettor’s whole history rather than just the pages a list happens to have loaded. See maxPages and isTruncated below for what happens on very large histories.

Covers v3 bets only; legacy v2 bets are not included.

Usage

import { getBetsReport, BetStatusFilter } from '@azuro-org/toolkit' const report = await getBetsReport({ chainId: 137, filter: { bettor: '0x...', status: BetStatusFilter.Settled }, }) // `single` is non-null whenever exactly one settlement token is present - the normal case, since a // chain has one bet token. Fall back to the first row of `byToken` only when there is more than one. const row = report.single ?? report.byToken[0] // `roi` is `null` when turnover is 0 (e.g. no settled bets yet) - never `NaN`, so guard before formatting const roiLabel = row && row.roi !== null ? `${row.roi.toFixed(2)}%` : '—' if (report.isTruncated) { // more bets matched the filter than the walk covered - present the figures as a lower bound, // e.g. "based on the most recent 50,000 bets", instead of a final total }

Props

type GetBetsReportParams = { chainId: ChainId filter: BetsFilter /** aborts the in-flight multi-page walk, e.g. when the filter changes mid-walk */ signal?: AbortSignal /** default: 1000, the maximum page size the subgraph accepts */ pageSize?: number /** default: 50, which caps the walk at 50 000 bets */ maxPages?: number }
type BetsFilter = { bettor: Address // bettor address affiliate?: Address // affiliate address status?: BetStatusFilter // lifecycle preset - narrows both the bet list and this report kind?: BetKind // single (Ordinar) vs combo (Express) createdFrom?: number // inclusive lower bound, unix seconds (matches `Bet.createdAt`) createdTo?: number // inclusive upper bound, unix seconds isFreebet?: boolean // `true` = freebet-funded only, `false` = own-funds only, omit = both /** @deprecated renamed to `status` - when both are set, `status` wins */ type?: BetStatusFilter } enum BetStatusFilter { Unredeemed = 'unredeemed', // ready to redeem bets Pending = 'pending', // not yet accepted on-chain Accepted = 'accepted', // accepted, not yet resolved Settled = 'settled', // resolved bets CashedOut = 'cashedOut', // cashed out bets } enum BetKind { Single = 'single', Combo = 'combo', } type ChainId = | 100 // Gnosis | 137 // Polygon | 80002 // Polygon Amoy | 88888 // Chiliz | 88882 // Chiliz Spicy | 8453 // Base | 84532 // Base Sepolia | 97 // BSC Testnet | 56 // BSC import { type Address } from 'viem'
⚠️

BetStatusFilter.Pending has no representation in the subgraph this function queries - orders that aren’t yet confirmed on-chain simply aren’t in that data set. Passing status: BetStatusFilter.Pending is therefore intentionally unhandled: it adds no status constraint at all, rather than narrowing the report down to pending bets.

Return Value

type GetBetsReportResult = BetsReportResult & { /** `true` when the walk stopped before the end of the bettor's history (it hit `maxPages`, or more * than 5000 bets share one timestamp) - the figures below are then a lower bound, not an exact * total, and the UI must surface that */ isTruncated: boolean } type BetsReportResult = { /** one row per settlement token, sorted by turnover descending */ byToken: BetsReportRow[] /** non-null iff exactly one token is present - render this instead of `byToken` whenever it is * non-null, which is the normal case since a chain has one bet token */ single: BetsReportRow | null /** every bet matching the filter, freebet-funded ones included - see count semantics below */ betsCount: number } type BetsReportRow = { token: { address: Address, decimals: number, symbol: string | null } /** own-funds bets only - freebet-funded ones are counted in `freebet.count` instead */ betsCount: number /** `betsCount` splits into these three */ settledCount: number pendingCount: number canceledCount: number /** decimal strings, already formatted with the token's decimals - the whole result is JSON-safe, * no bigint ever appears in it */ turnover: string returns: string profit: string /** percent, `profit / turnover * 100`; `null` when turnover is 0 - never `NaN` or `Infinity` */ roi: number | null /** sum of the stakes of unsettled, non-freebet bets */ atStake: string /** sum of the stakes returned by voided bets, excluded from every figure above */ refunded: string /** freebet-funded bets, excluded from every figure above */ freebet: { count: number canceledCount: number /** stakes of freebet-funded bets */ turnover: string /** the bettor's share of their payouts */ returns: string /** equals `returns` - the bettor staked none of their own money */ profit: string atStake: string refunded: string } }
ROI covers the bets that were at risk

A bet counts as settled once it is cashed out, Won or Lost - checked in that order, because a cashed-out bet keeps a notional settlement payout that would otherwise double-count it as Won too.

Two kinds of bet are left out of turnover, returns and roi:

  • pending ones, which only contribute to pendingCount and atStake, so ROI never has to guess at the outcome of a bet that hasn’t resolved yet;
  • voided ones, which only contribute to canceledCount and refunded. A void returns the stake in full, so the bet was never at risk; counting it as turnover would pull every ROI toward zero by exactly how unlucky a bettor was with cancellations, which says nothing about how they bet. A bet that was cashed out before being voided is not one of these - the bettor took a price and the money moved, so it stays a settled bet paid at its cashout amount.

profit = returns - turnover, and roi = profit / turnover * 100, truncated toward zero to 2 decimals.

Returns are the recorded payout

A won or lost bet returns the payout the subgraph recorded for it (0 for a lost bet), priced over its surviving legs; once the bet is redeemed, that is the amount actually paid out on chain. A cashed-out bet returns what the cash-out paid. Nothing is rebuilt from leg odds, whatever the bet’s creation time or legs.

A leg is voided exactly when its selection result is Canceled, which is what getBetsReport reads.

Freebet bets are excluded from ROI

A freebet-funded bet didn’t cost the bettor their own money, so folding it into turnover alongside own-funds bets would distort ROI. Every freebet-funded bet is instead counted in the row’s freebet bucket and never touches turnover, returns, profit, roi, or atStake at the row level. For the freebet line:

  • turnover is the sum of the freebets’ stakes;
  • returns is the sum of the bettor’s shares of their payouts. The liquidity pool pays a freebet’s whole payout to the freebet contract, which passes the bettor only a share: nothing when the payout does not exceed the stake, the payout minus the stake when the freebet’s amount is returnable, the whole payout otherwise. calcFreebetBettorShare applies this rule, and a freebet with no flag counts as returnable;
  • profit equals returns, because the bettor staked none of their own money, so whatever they receive is their gain.

Canceled freebets add their stake to freebet.refunded only. For example, a returnable freebet of 1.00 won at 1.50 gives turnover 1.00, returns 0.50 and profit 0.50; if its amount is not returnable, returns is 1.50.

ℹ️

Changed in Toolkit v7.2.0: freebet.returns used to sum the pool’s whole payout and freebet.profit was returns - turnover.

Building entries for calcBetsReport yourself

getBetsReport builds its entries from the subgraph. If you call calcBetsReport directly, an entry has this shape:

type BetsReportSelection = { /** @deprecated ignored: the report reads the payout recorded for the bet and never prices it from the odds of its legs */ odds?: string isCanceled: boolean } type BetsReportEntry = { id: string status: GraphBetStatus result: BetResult | null isCashedOut: boolean isFreebet: boolean /** whether a freebet's stake goes back to the freebet fund when it wins - decides the bettor's share; * null or absent counts as returnable; ignored on a bet funded by the bettor */ isFreebetAmountReturnable?: boolean | null /** @deprecated ignored: `rawPayout` already holds the amount actually paid once the bet is redeemed, * so the report reads it whether the bet is redeemed or not */ isRedeemed: boolean /** @deprecated ignored: the report reads the payout recorded for the bet, which is already priced by the rules * in force when the bet was placed */ createdAt?: number /** integer string, token base units */ rawAmount: string /** integer string, token base units: the payout recorded for the bet; a freebet's goes to the freebet contract, * which passes the bettor only a share of it */ rawPayout: string | null rawCashoutPayout: string | null /** every leg of the bet - a single is a bet with exactly one of them */ selections: BetsReportSelection[] token: { address: Address, decimals: number, symbol: string | null } }

isFreebetAmountReturnable is new and optional; an entry without it is valued as returnable. getBetsReport fills it from the subgraph. createdAt and each leg’s odds used to be required for the combo rebuild; they are now optional, deprecated and ignored, so entries built by older code still type-check. isRedeemed stays required in the type but is deprecated and ignored too: a redeemed bet’s rawPayout is already the amount actually paid.

Count semantics - do not double-count
FieldIncludes freebet bets?
row.betsCount / row.settledCount / row.pendingCount / row.canceledCount / row.atStake / row.refundedNo - freebet bets are excluded
row.freebet.countfreebet bets only
result.betsCount (top level)Yes - every matching bet

Never add row.betsCount + row.freebet.count unless the grand total across every token is genuinely what you need - in which case read result.betsCount directly.

isTruncated

true when the walk stopped before reaching the end of the bettor’s history: it hit maxPages, or, very rarely, more than 5000 bets share one creation timestamp, which the subgraph cannot page past. When that happens, every figure in the report is a lower bound rather than an exact total, and the UI must surface it - for example “based on the most recent 50,000 bets” - rather than presenting the numbers as final.