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
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 `maxPages` was hit before the walk reached the end of the bettor's history - 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
turnover: string
returns: string
profit: string
atStake: string
refunded: string
}
}ROI covers the bets that were at risk
A bet counts as settled once it is Won, Lost, or cashed out - 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, rounded to 2 decimals.
Returns are read, not reconstructed - with one exception
Returns come from the protocol’s own recorded payout. They are never reconstructed from odds: a bet’s
payout is not amount * settledOdds, and settledOdds is neither re-margined for a combo nor reduced
when one of its legs is voided.
The exception is the one shape where the recorded payout does not describe what the bet is worth: a
winning combo with at least one voided leg that has not been redeemed yet. Until redemption that payout
still credits the voided leg’s odds as if the leg had won. Those returns are rebuilt from the surviving
legs with calcMinOdds, which prices a combo the way the protocol does: the feed’s 1% fee is removed per
leg and re-applied once to the product instead of compounding once per leg - a plain product of the
surviving odds would understate the payout, by more the more legs the bet has. Redeemed bets always read
the recorded payout, which by then is the amount actually paid out.
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.
freebet.profit is returns - turnover mechanically, but since the stake wasn’t the bettor’s own
money, freebet.returns is the number that represents their actual gain.
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 hit maxPages before reaching the end of the bettor’s history. 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.