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
pendingCountandatStake, so ROI never has to guess at the outcome of a bet that hasn’t resolved yet; - voided ones, which only contribute to
canceledCountandrefunded. 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:
turnoveris the sum of the freebets’ stakes;returnsis 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.calcFreebetBettorShareapplies this rule, and a freebet with no flag counts as returnable;profitequalsreturns, 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
| Field | Includes freebet bets? |
|---|---|
row.betsCount / row.settledCount / row.pendingCount / row.canceledCount / row.atStake / row.refunded | No - freebet bets are excluded |
row.freebet.count | freebet 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.