Skip to content
58 changes: 58 additions & 0 deletions docs/adr/0040-bonus-funds-live-on-the-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ADR-0040: Bonus funds live on the grant, not in the wallet balance

**Date**: 2026-09-18
**Status**: Accepted

## Context

The platform needs a bonus a player must wager before they can withdraw it. Two shapes were
available.

The first is the one already shipped for chat gifts and rain: the bonus is credited into
`wallet_balance` like any other money, a `wallet_bonus_credit` row records how much of it is still
un-wagered, and `debitWithdrawableBalance` subtracts a proportional locked share at withdrawal
time. One balance, one number the player sees, and a formula standing between them and their own
cash.

The second keeps bonus funds out of the wallet entirely. A grant row carries its own balance, a
bet decides which side pays, and the money becomes real exactly once - when the wagering
requirement is met.

The specification asks for the second: bonus balance tracked separately from real balance, bets
drawing on both, and a forfeit that takes the bonus together with any winnings it produced. The
last clause is the one that decides it. Winnings from a bonus-funded stake have to be
attributable to the grant, and under one fungible balance there is nothing to attribute them to.

## Decision

`promo_grant.bonus_balance` is the bonus balance. Bonus funds never enter `wallet_balance` until
they convert, and conversion writes one `wallet_transaction` of type `bonus` for the exact amount.

`wallet_transaction` stays the real-money ledger and still records every bet and every win for
the full amount. `promo_grant_entry` is a second, append-only ledger recording which part of each
movement was bonus. Neither is bypassed and neither is a subset of the other.

## Consequences

Withdrawal needs no bonus logic at all. The proportional locked-share subquery inside
`debitWithdrawableBalance` exists only because the old model mixed the two kinds of money in one
row; with them separated, `amount >= requested` is a complete guard. Responsible-gambling limits
are unaffected: they read `wallet_transaction`, which still sees the full stake.

Reconciliation stays correct. It compares internal balances against on-chain custody, and bonus
money was never deposited. Holding it in `wallet_balance` would manufacture a permanent
unexplained surplus on every run.

Thirty-odd existing reads of `wallet_balance` - reconciliation, the custody sweep, swap, deposit,
withdrawal, the balance stream, admin reporting - keep their current meaning. The alternative, a
`kind` discriminator column, would have required a `kind = 'real'` predicate in every one of them,
and the one that got missed would be a player withdrawing bonus money.

The cost is that "total balance" is two reads rather than one: the wallet balance plus the
player's active grants. The client sums them.

The fungible model is removed rather than kept alongside. Two mechanisms implementing one product
rule means two answers to "what is locked" and a debit path with two bonus branches, which is a
bug waiting for whoever consolidates them. Chat gifts and rain move onto grants, which changes
their behaviour: that money now has to be wagered before it converts, instead of being spendable
immediately with a share locked at withdrawal.
22 changes: 22 additions & 0 deletions packages/core/src/contracts/adapters/bonus-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@
import type { BonusGrantSource } from '../schemas/promo.js';
import { createToken, type Token } from './token.js';

/**
* The terms a grant is created under. The grant row stores a snapshot of them, with the weight
* profile's rows resolved into it, so editing or deleting an offer or a profile never changes a
* bonus a player already holds.
*/
export type BonusGrantTerms = {
/** Wagering requirement as a multiple of the granted amount, as a decimal string. */
wageringMultiplier: string;
/** Days from the grant until it expires and what is left of it is forfeited. */
expiryDays: number;
/** Weight profile whose rows are copied onto the grant to score its bets. */
weightProfileId: string;
Comment thread
klaudia-blazyczek-blurify marked this conversation as resolved.
};

/**
* Who asked for the grant. A `manual` grant is an admin handing a player money, so it carries
* that admin onto the audit row; every other source is a rule firing with no person behind it.
*/
export type BonusGrantActor = { type: 'admin'; id: string } | { type: 'system' };

export type BonusGrantArgs = {
userId: string;
currency: string;
Expand All @@ -19,8 +39,10 @@ export type BonusGrantArgs = {
* replayed deposit or a re-run job resolves to the first grant instead of creating a second.
*/
sourceRef: string;
actor: BonusGrantActor;
/** Offer the grant is created from, when one exists. Absent for a manual or a job grant. */
offerId?: string;
terms: BonusGrantTerms;
};

export type BonusGrantOutcome =
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/contracts/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ export { WALLET_COMMANDS } from './wallet-commands.js';
export type { WagerContext, WagerProduct } from './wager-context.js';
export { WAGER_PRODUCTS, isWagerProduct } from './wager-context.js';

export type { BonusGrantCommands, BonusGrantArgs, BonusGrantOutcome } from './bonus-grants.js';
export type {
BonusGrantCommands,
BonusGrantActor,
BonusGrantArgs,
BonusGrantOutcome,
BonusGrantTerms,
} from './bonus-grants.js';
export { BONUS_GRANTS } from './bonus-grants.js';

export type {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/contracts/schemas/promo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from 'zod';
// Canonical promo value sets. Declared here rather than in the promo module because the
// isomorphic event and adapter contracts reference them and cannot import from a domain.

/** What caused a grant. Half of its idempotency key. */
export const BONUS_GRANT_SOURCES = [
'deposit',
'manual',
Expand All @@ -13,6 +14,11 @@ export const BONUS_GRANT_SOURCES = [
'rain',
] as const;

/**
* `pending` is a grant that is claimed but not yet funded, `cancelled` its only exit - nothing
* was credited, so there is nothing to lose. Everything after funding ends in `completed`,
* `expired` or `forfeited`.
*/
export const BONUS_GRANT_STATUSES = [
'pending',
'active',
Expand All @@ -22,6 +28,7 @@ export const BONUS_GRANT_STATUSES = [
'cancelled',
] as const;

/** Why an active grant was taken away. Recorded on every forfeit, for the regulator. */
export const BONUS_FORFEIT_REASONS = [
'self_exclusion',
'account_closed',
Expand Down
Loading
Loading